diff --git a/aworld/config/task_loader.py b/aworld/config/task_loader.py index 748c69f5d..88fe77269 100644 --- a/aworld/config/task_loader.py +++ b/aworld/config/task_loader.py @@ -319,6 +319,11 @@ async def _load_skill_agent( f"missing agent {agent_id}: failed to load skill content for '{skill_name}' " f"from {skills_path}: {e}" ) + + runtime_skill_config = registry.build_skill_config( + descriptor.skill_id, + active_override=True, + ) # Build Agent from skill (consistent with skill_service logic) agent_config_dict = agent_def.get("config", {}) @@ -345,6 +350,17 @@ async def _load_skill_agent( agent_def.get("mcp_config") ) ) + + # Preserve framework-loaded skill metadata for downstream runtime features + # such as remote sandbox execution-asset staging without re-enabling the + # legacy skill tool path on these agentic skills. + merged_skill_configs = dict(agent.conf.skill_configs or {}) + merged_skill_configs[skill_name] = runtime_skill_config + + agent.conf.skill_configs = merged_skill_configs + agent.skill_configs = merged_skill_configs + if agent.sandbox is not None: + agent.sandbox.skill_configs = merged_skill_configs return agent diff --git a/aworld/sandbox/builtin/base.py b/aworld/sandbox/builtin/base.py index 732eab3ad..193d9091d 100644 --- a/aworld/sandbox/builtin/base.py +++ b/aworld/sandbox/builtin/base.py @@ -11,6 +11,7 @@ FILESYSTEM_TOOL_MAPPING = { "read_file": "read_file", "write_file": "write_file", + "write_file_base64": "write_file_base64", "edit_file": "replace_in_file", "replace_in_file": "replace_in_file", "edit_file_range": "edit_file_range", @@ -88,4 +89,3 @@ def decorator(func: Callable) -> Callable: func._fallback = fallback_to_builtin return func return decorator - diff --git a/aworld/sandbox/implementations/sandbox.py b/aworld/sandbox/implementations/sandbox.py index d13301b80..87b2732ac 100644 --- a/aworld/sandbox/implementations/sandbox.py +++ b/aworld/sandbox/implementations/sandbox.py @@ -88,6 +88,8 @@ def __init__( self._initialized = False self._file_namespace = None self._terminal_namespace = None + self._remote_skill_execution_roots: dict[tuple[str, str], str] = {} + self._remote_skill_execution_base_dir: str | None = None user_mcp_config = copy.deepcopy(mcp_config) if mcp_config else {} user_mcp_servers = mcp_servers or [] @@ -1315,5 +1317,14 @@ def get_skill_list(self) -> Optional[Any]: return None return self._skill_configs + async def ensure_skill_execution_assets_ready( + self, + skill_name: str, + skill_config: Dict[str, Any], + ) -> str: + from aworld.sandbox.skill_sync import ensure_remote_skill_assets_ready + + return await ensure_remote_skill_assets_ready(self, skill_name, skill_config) + def __del__(self): super().__del__() diff --git a/aworld/sandbox/namespaces/base.py b/aworld/sandbox/namespaces/base.py index 6c96105c6..6da68afca 100644 --- a/aworld/sandbox/namespaces/base.py +++ b/aworld/sandbox/namespaces/base.py @@ -33,6 +33,11 @@ def resolve_service_name(sandbox: Any, logical_name: str) -> str: (comma-separated) contains logical_name. Fallback: return logical_name. """ mcp_config = getattr(sandbox, "mcp_config", None) or getattr(sandbox, "_mcp_config", None) + return resolve_service_name_from_config(mcp_config, logical_name) + + +def resolve_service_name_from_config(mcp_config: Any, logical_name: str) -> str: + """Resolve a logical service name against an MCP config dict.""" if not mcp_config: return logical_name servers = mcp_config.get("mcpServers") or {} @@ -48,9 +53,41 @@ def resolve_service_name(sandbox: Any, logical_name: str) -> str: names = [n.strip() for n in mcp_servers_header.split(",") if n.strip()] if logical_name in names: return key + server_suffix_alias = f"{logical_name}-server" + if server_suffix_alias in servers: + return server_suffix_alias + for key in servers: + if str(key).strip().lower().endswith(f"-{server_suffix_alias}"): + return key return logical_name +def service_matches_logical_name( + mcp_config: Any, + server_name: str, + logical_name: str, +) -> bool: + """Return whether a concrete server name should be treated as a logical service.""" + if not server_name: + return False + normalized_server_name = str(server_name).strip() + if normalized_server_name == logical_name: + return True + + servers = (mcp_config or {}).get("mcpServers") or {} + server_config = servers.get(normalized_server_name, {}) + headers = server_config.get("headers") or {} + mcp_servers_header = (headers.get("MCP_SERVERS") or "").strip() + if mcp_servers_header: + names = [n.strip() for n in mcp_servers_header.split(",") if n.strip()] + if logical_name in names: + return True + + server_suffix_alias = f"{logical_name}-server" + normalized_lower = normalized_server_name.lower() + return normalized_lower == server_suffix_alias or normalized_lower.endswith(f"-{server_suffix_alias}") + + def _parse_action_results(results: List[Any]) -> Dict[str, Any]: """Parse List[ActionResult] from call_tool into normalized dict.""" if not results: diff --git a/aworld/sandbox/namespaces/file.py b/aworld/sandbox/namespaces/file.py index 6c0449be8..6eb34a7e1 100644 --- a/aworld/sandbox/namespaces/file.py +++ b/aworld/sandbox/namespaces/file.py @@ -32,6 +32,10 @@ async def write_file(self, path: str, content: str) -> Dict[str, Any]: """Write content to file.""" return await self._call_tool("write_file", path=path, content=content) + async def write_file_base64(self, path: str, content_base64: str) -> Dict[str, Any]: + """Write base64-decoded binary content to file.""" + return await self._call_tool("write_file_base64", path=path, content_base64=content_base64) + async def edit_file( self, path: str, diff --git a/aworld/sandbox/run/mcp_servers.py b/aworld/sandbox/run/mcp_servers.py index 1421c7569..49a8de83d 100644 --- a/aworld/sandbox/run/mcp_servers.py +++ b/aworld/sandbox/run/mcp_servers.py @@ -1,13 +1,21 @@ import asyncio import json import os +import re +import shlex import threading import traceback import uuid from datetime import datetime +from pathlib import Path from aworld.core.context.base import Context from aworld.logs.util import logger +from aworld.sandbox.namespaces.base import ( + resolve_service_name_from_config, + service_matches_logical_name, +) +from aworld.skills.execution_assets import build_skill_path_aliases from aworld.utils.common import sync_exec @@ -33,6 +41,10 @@ from aworld.sandbox.implementations.sandbox import Sandbox +_TERMINAL_EXECUTION_TOOL_NAMES = {"run_code", "execute_command", "mcp_execute_command"} +_TERMINAL_COMMAND_PARAMETER_KEYS = {"code", "command"} + + def _coalesce_tool_result_content(content_items: List[str]) -> Any: """Keep single text outputs as plain strings instead of JSON array strings.""" if not content_items: @@ -243,50 +255,548 @@ async def check_tool_params(self, context: Context, server_name: str, tool_name: bool: Whether parameter check passed """ # Ensure tool_list is loaded - if not self.tool_list or not context: + if not self.tool_list: return False if not self.mcp_servers or not self.mcp_config: return False + # Build unique identifier for the tool + tool_identifier = f"{server_name}__{tool_name}" + + # Find corresponding tool in tool_list + target_tool = None + for tool in self.tool_list: + if tool.get("type") == "function" and tool.get("function", {}).get("name") == tool_identifier: + target_tool = tool + break + + if not target_tool: + logger.warning(f"Tool not found: {tool_identifier}") + return False + + # Get tool parameter definitions + function_info = target_tool.get("function", {}) + tool_parameters = function_info.get("parameters", {}) + properties = tool_parameters.get("properties", {}) + + if "session_id" in properties and context: + if hasattr(context, 'session_id') and context.session_id: + parameter["session_id"] = context.session_id + logger.info(f"Auto-added session_id: {context.session_id}") + + if "task_id" in properties and context: + if hasattr(context, 'task_id') and context.task_id: + parameter["task_id"] = context.task_id + logger.info(f"Auto-added task_id: {context.task_id}") + + await self._prepare_remote_skill_execution_params( + context=context, + server_name=server_name, + tool_name=tool_name, + parameter=parameter, + ) + + return True + + async def _prepare_remote_skill_execution_params( + self, + *, + context: Context | None, + server_name: str, + tool_name: str, + parameter: Dict[str, Any], + ) -> None: + if ( + not isinstance(parameter, dict) + or not self.sandbox + or getattr(self.sandbox, "mode", "local") != "remote" + or not self._is_terminal_service(server_name) + or tool_name not in _TERMINAL_EXECUTION_TOOL_NAMES + ): + return + + for key in _TERMINAL_COMMAND_PARAMETER_KEYS: + raw_value = parameter.get(key) + if not isinstance(raw_value, str) or not raw_value.strip(): + continue + parameter[key] = await self._rewrite_remote_skill_paths( + raw_value, + context=context, + ) + + def _is_terminal_service(self, server_name: str) -> bool: + return service_matches_logical_name(self.mcp_config, server_name, "terminal") + + async def _rewrite_remote_skill_paths( + self, + command_text: str, + *, + context: Context | None, + ) -> str: + rewritten = command_text + active_skill_names = await self._get_active_skill_names(context) + candidate_skill_names = self._resolve_candidate_skill_names( + command_text=command_text, + active_skill_names=active_skill_names, + ) + relative_path_owners = self._build_relative_path_owners(candidate_skill_names) + relative_directory_owners = self._build_relative_directory_owners(candidate_skill_names) + + for skill_name in candidate_skill_names: + skill_config = (self.skill_configs or {}).get(skill_name) + if not isinstance(skill_config, dict): + continue + execution_assets = dict(skill_config.get("execution_assets", {}) or {}) + if not execution_assets.get("enabled"): + continue + + asset_root_value = str(skill_config.get("asset_root", "") or "").strip() + if not asset_root_value: + continue + asset_root = str(Path(asset_root_value).resolve()) + relative_paths = [ + str(path).strip() + for path in execution_assets.get("relative_paths", []) or [] + if str(path).strip() + ] + if not relative_paths: + continue + + host_file_paths = { + str((Path(asset_root) / relative_path).resolve()): relative_path + for relative_path in relative_paths + } + path_aliases = self._get_skill_path_aliases( + skill_name=skill_name, + skill_config=skill_config, + ) + relative_path_matches = [ + relative_path + for relative_path in relative_paths + if self._should_rewrite_relative_path( + command_text=rewritten, + relative_path=relative_path, + skill_name=skill_name, + active_skill_names=active_skill_names, + relative_path_owners=relative_path_owners, + ) + ] + relative_directory_matches = [ + relative_dir + for relative_dir in self._build_relative_directories(relative_paths) + if self._should_rewrite_relative_directory( + command_text=rewritten, + relative_dir=relative_dir, + skill_name=skill_name, + active_skill_names=active_skill_names, + relative_directory_owners=relative_directory_owners, + ) + ] + root_referenced = asset_root in rewritten or any( + self._skill_root_occurs_in_command(rewritten, alias) + for alias in path_aliases + ) + file_referenced = any(host_path in rewritten for host_path in host_file_paths) + if ( + not root_referenced + and not file_referenced + and not relative_path_matches + and not relative_directory_matches + ): + continue + + remote_root = await self.sandbox.ensure_skill_execution_assets_ready( + skill_name, + skill_config, + ) + + for host_path, relative_path in sorted( + host_file_paths.items(), + key=lambda item: len(item[0]), + reverse=True, + ): + remote_path = str(Path(remote_root) / relative_path) + rewritten = self._rewrite_path_reference( + rewritten, + host_path, + remote_path, + ) + + rewritten = self._rewrite_virtual_skill_root( + rewritten, + path_aliases, + remote_root, + ) + rewritten = self._rewrite_cd_command_root( + rewritten, + asset_root, + remote_root, + ) + for relative_dir in relative_directory_matches: + rewritten = self._rewrite_relative_cd_directory( + rewritten, + relative_dir, + str(Path(remote_root) / relative_dir), + ) + for relative_path in relative_path_matches: + remote_path = str(Path(remote_root) / relative_path) + rewritten = self._rewrite_relative_path_reference( + rewritten, + relative_path, + remote_path, + ) + + return rewritten + + async def _get_active_skill_names(self, context: Context | None) -> list[str]: + if not context or not hasattr(context, "get_active_skills"): + return [] + getter = getattr(context, "get_active_skills") + if not callable(getter): + return [] + namespace = self._resolve_skill_namespace(context) try: - # Build unique identifier for the tool - tool_identifier = f"{server_name}__{tool_name}" - - # Find corresponding tool in tool_list - target_tool = None - for tool in self.tool_list: - if tool.get("type") == "function" and tool.get("function", {}).get("name") == tool_identifier: - target_tool = tool - break - - if not target_tool: - logger.warning(f"Tool not found: {tool_identifier}") - return False - - # Get tool parameter definitions - function_info = target_tool.get("function", {}) - tool_parameters = function_info.get("parameters", {}) - properties = tool_parameters.get("properties", {}) - - # Check if session_id or task_id parameters are needed - # Check if session_id is needed - if "session_id" in properties: - if hasattr(context, 'session_id') and context.session_id: - parameter["session_id"] = context.session_id - logger.info(f"Auto-added session_id: {context.session_id}") - - # Check if task_id is needed - if "task_id" in properties: - if hasattr(context, 'task_id') and context.task_id: - parameter["task_id"] = context.task_id - logger.info(f"Auto-added task_id: {context.task_id}") + skills = await getter(namespace=namespace) + except TypeError: + try: + skills = await getter(namespace) + except TypeError: + skills = await getter() + except Exception: + return [] + if not isinstance(skills, list): + return [] + return [str(skill).strip() for skill in skills if str(skill).strip()] + @staticmethod + def _resolve_skill_namespace(context: Context | None) -> str | None: + if not context: + return None + agent_info = getattr(context, "agent_info", None) + current_agent_id = getattr(agent_info, "current_agent_id", None) + if isinstance(current_agent_id, str) and current_agent_id.strip(): + return current_agent_id.strip() + return None + + def _resolve_candidate_skill_names( + self, + *, + command_text: str, + active_skill_names: list[str], + ) -> list[str]: + ordered: list[str] = [] + seen: set[str] = set() + + for match in re.finditer(r"/skills/(?P[A-Za-z0-9_.-]+)/", command_text): + skill_name = match.group("skill_name") + if skill_name in self.skill_configs and skill_name not in seen: + ordered.append(skill_name) + seen.add(skill_name) + + for skill_name in active_skill_names: + if skill_name in self.skill_configs and skill_name not in seen: + ordered.append(skill_name) + seen.add(skill_name) + + for skill_name in self.skill_configs: + if skill_name not in seen: + ordered.append(skill_name) + seen.add(skill_name) + + return ordered + + @classmethod + def _build_relative_directories(cls, relative_paths: list[str]) -> list[str]: + directories: list[str] = [] + seen: set[str] = set() + for relative_path in relative_paths: + path = Path(str(relative_path).strip()) + parents = list(path.parents) + parents.reverse() + for parent in parents: + candidate = str(parent).strip() + if not candidate or candidate == "." or candidate in seen: + continue + directories.append(candidate) + seen.add(candidate) + return directories + + def _build_relative_path_owners( + self, + candidate_skill_names: list[str], + ) -> dict[str, list[str]]: + owners: dict[str, list[str]] = {} + for skill_name in candidate_skill_names: + skill_config = (self.skill_configs or {}).get(skill_name) + if not isinstance(skill_config, dict): + continue + execution_assets = dict(skill_config.get("execution_assets", {}) or {}) + for relative_path in execution_assets.get("relative_paths", []) or []: + normalized = str(relative_path).strip() + if not normalized: + continue + owners.setdefault(normalized, []).append(skill_name) + return owners + + def _build_relative_directory_owners( + self, + candidate_skill_names: list[str], + ) -> dict[str, list[str]]: + owners: dict[str, list[str]] = {} + for skill_name in candidate_skill_names: + skill_config = (self.skill_configs or {}).get(skill_name) + if not isinstance(skill_config, dict): + continue + execution_assets = dict(skill_config.get("execution_assets", {}) or {}) + relative_paths = [ + str(relative_path).strip() + for relative_path in execution_assets.get("relative_paths", []) or [] + if str(relative_path).strip() + ] + for relative_dir in self._build_relative_directories(relative_paths): + owners.setdefault(relative_dir, []).append(skill_name) + return owners + + def _should_rewrite_relative_path( + self, + *, + command_text: str, + relative_path: str, + skill_name: str, + active_skill_names: list[str], + relative_path_owners: dict[str, list[str]], + ) -> bool: + if not self._path_occurs_in_command(command_text, relative_path) and not self._path_occurs_in_command( + command_text, + f"./{relative_path}", + ): + return False + if skill_name not in active_skill_names: + return False + + owners = relative_path_owners.get(relative_path, []) + if not owners: + return False + if len(owners) <= 1: return True - except Exception as e: - logger.warning(f"Error checking tool parameters: {e}") + active_owners = [owner for owner in owners if owner in active_skill_names] + return len(active_owners) == 1 and active_owners[0] == skill_name + + def _should_rewrite_relative_directory( + self, + *, + command_text: str, + relative_dir: str, + skill_name: str, + active_skill_names: list[str], + relative_directory_owners: dict[str, list[str]], + ) -> bool: + if not self._cd_command_targets_path(command_text, relative_dir): + return False + if skill_name not in active_skill_names: + return False + + owners = relative_directory_owners.get(relative_dir, []) + if not owners: + return False + if len(owners) <= 1: + return True + + active_owners = [owner for owner in owners if owner in active_skill_names] + return len(active_owners) == 1 and active_owners[0] == skill_name + + @staticmethod + def _get_skill_path_aliases( + *, + skill_name: str, + skill_config: dict[str, Any], + ) -> list[str]: + merged = build_skill_path_aliases(skill_name=skill_name) + aliases = skill_config.get("path_aliases") + if isinstance(aliases, list): + for alias in aliases: + candidate = str(alias).strip() + if candidate and candidate not in merged: + merged.append(candidate) + return merged + + @staticmethod + def _path_occurs_in_command(command_text: str, path_text: str) -> bool: + pattern = re.compile( + rf"(? bool: + pattern = re.compile( + rf"(? bool: + for candidate in (path_text, f"./{path_text}"): + pattern = re.compile( + rf"(?P\bcd\s+)(?P['\"]?)(?P{re.escape(candidate)})(?P=quote)" + rf"(?=(?:\s|&&|;|\|\||$))" + ) + if pattern.search(command_text): + return True + return False + + @classmethod + def _rewrite_relative_path_reference( + cls, + command_text: str, + relative_path: str, + remote_path: str, + ) -> str: + rewritten = command_text + for candidate in (f"./{relative_path}", relative_path): + rewritten = cls._rewrite_path_reference( + rewritten, + candidate, + remote_path, + ) + return rewritten + + @classmethod + def _rewrite_cd_command_root(cls, command_text: str, asset_root: str, remote_root: str) -> str: + return cls._rewrite_cd_command_path( + command_text, + asset_root, + remote_root, + ) + + @classmethod + def _rewrite_relative_cd_directory( + cls, + command_text: str, + relative_dir: str, + remote_path: str, + ) -> str: + rewritten = command_text + for candidate in (relative_dir, f"./{relative_dir}"): + rewritten = cls._rewrite_cd_command_path( + rewritten, + candidate, + remote_path, + ) + return rewritten + + @classmethod + def _rewrite_cd_command_path( + cls, + command_text: str, + original_path: str, + replacement_path: str, + ) -> str: + pattern = re.compile( + rf"(?P\bcd\s+)(?P['\"]?)(?P{re.escape(original_path)})(?P=quote)" + rf"(?=(?:\s|&&|;|\|\||$))" + ) + return pattern.sub( + lambda match: ( + f"{match.group('prefix')}" + f"{match.group('quote')}" + f"{cls._format_shell_path_replacement(command_text, match.start('path'), replacement_path)}" + f"{match.group('quote')}" + ), + command_text, + ) + + @classmethod + def _rewrite_virtual_skill_root( + cls, + command_text: str, + path_aliases: list[str], + remote_root: str, + ) -> str: + rewritten = command_text + for virtual_root in path_aliases: + rewritten = cls._rewrite_path_reference( + rewritten, + virtual_root, + remote_root, + allow_suffix=True, + ) + return rewritten + + @classmethod + def _rewrite_path_reference( + cls, + command_text: str, + path_text: str, + replacement_path: str, + *, + allow_suffix: bool = False, + ) -> str: + suffix_pattern = r"(?=(?:/|[^A-Za-z0-9_.-]|$))" if allow_suffix else r"(?![A-Za-z0-9_./-])" + pattern = re.compile( + rf"(?{re.escape(path_text)}){suffix_pattern}" + ) + return pattern.sub( + lambda match: cls._format_shell_path_replacement( + command_text, + match.start("path"), + replacement_path, + ), + command_text, + ) + + @classmethod + def _format_shell_path_replacement( + cls, + command_text: str, + start_index: int, + replacement_path: str, + ) -> str: + if cls._shell_quote_context(command_text, start_index): + return replacement_path + if cls._looks_like_windows_path(replacement_path): + if any(char.isspace() for char in replacement_path): + return f'"{replacement_path}"' + return replacement_path + return shlex.quote(replacement_path) + + @staticmethod + def _shell_quote_context(command_text: str, position: int) -> str | None: + quote: str | None = None + escaped = False + for char in command_text[:position]: + if quote == "'": + if char == "'": + quote = None + continue + if quote == '"': + if escaped: + escaped = False + continue + if char == "\\": + escaped = True + continue + if char == '"': + quote = None + continue + if escaped: + escaped = False + continue + if char == "\\": + escaped = True + continue + if char in {"'", '"'}: + quote = char + return quote + + @staticmethod + def _looks_like_windows_path(path_text: str) -> bool: + candidate = str(path_text or "").strip() + if not candidate: return False + return bool(re.match(r"^(?:[A-Za-z]:[\\/]|\\\\)", candidate)) async def call_tool( self, @@ -454,12 +964,24 @@ async def progress_callback( logger.warning(f"Error calling progress callback: {e}") # Check and supplement tool parameters - await self.check_tool_params( - context=context, - server_name=server_name, - tool_name=tool_name, - parameter=parameter - ) + try: + await self.check_tool_params( + context=context, + server_name=server_name, + tool_name=tool_name, + parameter=parameter + ) + except Exception as e: + logger.warning(f"Error checking tool parameters: {e}") + action_result = _build_tool_call_failure_result( + server_name=server_name, + tool_name=tool_name, + parameter=parameter, + error=e, + ) + results.append(action_result) + self._update_metadata(result_key, {"error": str(e)}, operation_info) + continue call_result_raw = None action_result = ActionResult( @@ -558,6 +1080,7 @@ async def progress_callback( metadata["artifacts"] = artifact_datas action_result = ActionResult( + success=True, tool_name=server_name, action_name=tool_name, content=_coalesce_tool_result_content(content_list), diff --git a/aworld/sandbox/skill_sync.py b/aworld/sandbox/skill_sync.py new file mode 100644 index 000000000..6a6eaa001 --- /dev/null +++ b/aworld/sandbox/skill_sync.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import base64 +import re +import shlex +from pathlib import Path +from typing import Any + + +async def ensure_remote_skill_assets_ready( + sandbox: Any, + skill_name: str, + skill_config: dict[str, Any], +) -> str: + execution_assets = dict(skill_config.get("execution_assets", {}) or {}) + if getattr(sandbox, "mode", "local") != "remote": + return str(skill_config.get("asset_root", "") or "") + + if not execution_assets.get("enabled"): + raise RuntimeError(f"Skill '{skill_name}' has no remote execution assets to sync") + + digest = str(execution_assets.get("digest", "") or "").strip() + relative_paths = [ + str(path).strip() + for path in execution_assets.get("relative_paths", []) or [] + if str(path).strip() + ] + if not digest or not relative_paths: + raise RuntimeError( + f"Skill '{skill_name}' execution asset metadata is incomplete for remote sync" + ) + + cache = _get_or_create_root_cache(sandbox) + cache_key = (skill_name, digest) + if cache_key in cache: + return cache[cache_key] + + asset_root = Path(str(skill_config.get("asset_root", "") or "")).resolve() + if not asset_root.exists(): + raise RuntimeError(f"Skill '{skill_name}' asset root does not exist: {asset_root}") + + remote_base_dir = await _resolve_remote_base_dir(sandbox) + remote_root = str(Path(remote_base_dir) / ".aworld" / "skills" / skill_name / digest) + + await _require_success( + await sandbox.file.create_directory(remote_root), + f"create remote skill root for '{skill_name}'", + ) + + for relative_path in relative_paths: + source_path = (asset_root / relative_path).resolve() + try: + source_path.relative_to(asset_root) + except ValueError as exc: + raise RuntimeError( + f"Skill '{skill_name}' execution asset escapes asset_root: {relative_path}" + ) from exc + if not source_path.is_file(): + raise RuntimeError( + f"Skill '{skill_name}' execution asset file not found: {relative_path}" + ) + target_path = str(Path(remote_root) / relative_path) + try: + content = source_path.read_text(encoding="utf-8") + except UnicodeDecodeError: + if not hasattr(sandbox.file, "write_file_base64"): + raise RuntimeError( + f"Skill '{skill_name}' execution asset is not UTF-8 text and remote binary write is unavailable: " + f"{relative_path}" + ) + content_base64 = base64.b64encode(source_path.read_bytes()).decode("ascii") + await _require_success( + await sandbox.file.write_file_base64(target_path, content_base64), + f"write remote binary execution asset '{relative_path}' for '{skill_name}'", + ) + else: + await _require_success( + await sandbox.file.write_file(target_path, content), + f"write remote execution asset '{relative_path}' for '{skill_name}'", + ) + await _preserve_remote_permissions( + sandbox, + source_path=source_path, + target_path=target_path, + skill_name=skill_name, + relative_path=relative_path, + ) + + cache[cache_key] = remote_root + return remote_root + + +def _get_or_create_root_cache(sandbox: Any) -> dict[tuple[str, str], str]: + cache = getattr(sandbox, "_remote_skill_execution_roots", None) + if not isinstance(cache, dict): + cache = {} + setattr(sandbox, "_remote_skill_execution_roots", cache) + return cache + + +async def _resolve_remote_base_dir(sandbox: Any) -> str: + cached = getattr(sandbox, "_remote_skill_execution_base_dir", None) + if cached: + return str(cached) + + result = await sandbox.file.list_allowed_directories() + await _require_success(result, "list remote allowed directories") + directories = _parse_allowed_directories(result.get("data")) + if not directories: + raise RuntimeError("Remote filesystem did not expose any allowed directories") + base_dir = directories[0] + setattr(sandbox, "_remote_skill_execution_base_dir", base_dir) + return base_dir + + +def _parse_allowed_directories(raw: Any) -> list[str]: + if isinstance(raw, list): + return [str(item).strip() for item in raw if str(item).strip()] + if isinstance(raw, str): + lines = [line.strip() for line in raw.splitlines() if line.strip()] + if lines and lines[0].lower().startswith("allowed directories"): + lines = lines[1:] + return lines + return [] + + +async def _require_success(result: dict[str, Any], phase: str) -> None: + if result and result.get("success", True): + return + error = None + if isinstance(result, dict): + error = result.get("error") or result.get("data") + raise RuntimeError(f"Failed to {phase}: {error or 'unknown error'}") + + +async def _preserve_remote_permissions( + sandbox: Any, + *, + source_path: Path, + target_path: str, + skill_name: str, + relative_path: str, +) -> None: + permission_bits = source_path.stat().st_mode & 0o777 + executable_bits = permission_bits & 0o111 + if not executable_bits: + return + if not _remote_supports_posix_permissions(sandbox, target_path=target_path): + return + + terminal = getattr(sandbox, "terminal", None) + if terminal is None or not hasattr(terminal, "run_code"): + raise RuntimeError( + f"Failed to preserve remote execution asset permissions for '{skill_name}': " + "sandbox terminal namespace is unavailable" + ) + + quoted_target_path = shlex.quote(target_path) + result = await terminal.run_code(f"chmod {permission_bits:o} {quoted_target_path}") + await _require_success( + result, + f"preserve permissions for remote execution asset '{relative_path}' in '{skill_name}'", + ) + + +def _remote_supports_posix_permissions(sandbox: Any, *, target_path: str) -> bool: + cached = getattr(sandbox, "_remote_skill_execution_supports_posix_permissions", None) + if isinstance(cached, bool): + return cached + + base_dir = getattr(sandbox, "_remote_skill_execution_base_dir", None) + probe_path = str(base_dir or target_path or "") + supports_posix = not _looks_like_windows_path(probe_path) + setattr(sandbox, "_remote_skill_execution_supports_posix_permissions", supports_posix) + return supports_posix + + +def _looks_like_windows_path(path_text: str) -> bool: + candidate = str(path_text or "").strip() + if not candidate: + return False + return bool(re.match(r"^(?:[A-Za-z]:[\\/]|\\\\)", candidate)) diff --git a/aworld/sandbox/tool_servers/filesystem/src/main.py b/aworld/sandbox/tool_servers/filesystem/src/main.py index fed1e62a9..53ab1e072 100644 --- a/aworld/sandbox/tool_servers/filesystem/src/main.py +++ b/aworld/sandbox/tool_servers/filesystem/src/main.py @@ -21,6 +21,7 @@ from utils.file_ops import ( read_file as read_file_content, write_file as write_file_content, + write_file_base64 as write_file_base64_content, head_file, tail_file, read_file_lines, @@ -134,6 +135,23 @@ async def write_file( return TextContent(type="text", text=f"Successfully wrote to {path}") +@mcp.tool( + description=( + "Create or overwrite a file from base64-encoded bytes. " + "Completely replaces existing file content and automatically creates parent directories." + ) +) +async def write_file_base64( + ctx: Context, + path: str = Field(description="File path to write"), + content_base64: str = Field(description="Base64-encoded file content"), +) -> TextContent: + """Create or overwrite a file from base64-encoded bytes.""" + valid_path = await validate_path(path, allowed_directories) + await write_file_base64_content(valid_path, content_base64) + return TextContent(type="text", text=f"Successfully wrote binary content to {path}") + + @mcp.tool(description="Create directory. Automatically creates parent directories recursively. Silently succeeds if directory already exists.") async def create_directory( ctx: Context, diff --git a/aworld/sandbox/tool_servers/filesystem/src/utils/file_ops.py b/aworld/sandbox/tool_servers/filesystem/src/utils/file_ops.py index 272fa9f10..910f67865 100644 --- a/aworld/sandbox/tool_servers/filesystem/src/utils/file_ops.py +++ b/aworld/sandbox/tool_servers/filesystem/src/utils/file_ops.py @@ -64,6 +64,29 @@ def _write_file_sync(path: str, content: str) -> None: raise +async def write_file_base64(path: str, content_base64: str) -> None: + """Atomically write base64-decoded bytes to a file.""" + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _write_file_base64_sync, path, content_base64) + + +def _write_file_base64_sync(path: str, content_base64: str) -> None: + """Synchronously decode base64 content and write raw bytes to a file.""" + dir_path = Path(path).parent + dir_path.mkdir(parents=True, exist_ok=True) + + data = base64.b64decode(content_base64, validate=True) + with tempfile.NamedTemporaryFile(mode="wb", dir=dir_path, delete=False) as tmp: + tmp.write(data) + tmp_path = tmp.name + + try: + Path(tmp_path).replace(path) + except Exception: + Path(tmp_path).unlink(missing_ok=True) + raise + + async def head_file(path: str, num_lines: int) -> str: """Read the first N lines of a text file.""" loop = asyncio.get_event_loop() @@ -447,4 +470,3 @@ def _get_file_stats_sync(path: str) -> dict: "isFile": Path(path).is_file(), "permissions": oct(stat.st_mode)[-3:], } - diff --git a/aworld/skills/execution_assets.py b/aworld/skills/execution_assets.py new file mode 100644 index 000000000..c50259e16 --- /dev/null +++ b/aworld/skills/execution_assets.py @@ -0,0 +1,449 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from hashlib import sha256 +from pathlib import Path +from typing import Any + + +_DEFAULT_EXECUTION_SUFFIXES = { + ".bash", + ".cjs", + ".cfg", + ".conf", + ".config", + ".csv", + ".ini", + ".js", + ".jinja", + ".j2", + ".json", + ".cts", + ".mts", + ".mjs", + ".py", + ".sh", + ".sql", + ".template", + ".tmpl", + ".toml", + ".ts", + ".tsv", + ".txt", + ".yaml", + ".yml", + ".zsh", +} + +_UNDERSTANDING_FILES = {"SKILL.md", "skill.md"} +_SCRIPT_REFERENCE_RE = re.compile(r"(?Pscripts/[A-Za-z0-9_./-]+)") +_RELATIVE_REFERENCE_RE = re.compile(r"(?P\./[A-Za-z0-9_./-]+)") +_SKILL_VIRTUAL_REFERENCE_RE = re.compile( + r"(?:(?:~/(?:\./)?|(?:\./)?)?\.claude/skills|/skills)/(?P[A-Za-z0-9_.-]+)/(?P[A-Za-z0-9_./-]+)" +) +_SKILL_PATH_ALIAS_RE = re.compile( + r"(?P(?:~/(?:\./)?|(?:\./)?)?\.claude/skills|/skills)/(?P[A-Za-z0-9_.-]+)" + r"(?=(?:/|[^A-Za-z0-9_.-]|$))" +) + + +@dataclass(frozen=True) +class ExecutionAssetManifest: + root: Path + relative_paths: tuple[str, ...] + + +def parse_declared_execution_assets(raw: Any) -> list[str] | None: + if raw in (None, "", [], (), set()): + return None + + if isinstance(raw, str): + text = raw.strip() + if not text: + return None + try: + parsed = json.loads(text) + except json.JSONDecodeError: + return [text] + return parse_declared_execution_assets(parsed) + + if isinstance(raw, dict): + for key in ("relative_paths", "paths", "include"): + if key in raw: + return parse_declared_execution_assets(raw.get(key)) + enabled = raw.get("enabled") + if enabled is False: + return [] + return None + + if isinstance(raw, (list, tuple, set)): + return [str(item).strip() for item in raw if str(item).strip()] + + return [str(raw).strip()] + + +def build_execution_asset_manifest( + root: Path, + declared_assets: list[str] | None, +) -> ExecutionAssetManifest: + root = Path(root).resolve() + if declared_assets: + relative_paths = tuple(_normalize_declared_paths(root, declared_assets)) + else: + relative_paths = tuple(_collect_default_execution_assets(root)) + return ExecutionAssetManifest(root=root, relative_paths=relative_paths) + + +def compute_execution_asset_digest(manifest: ExecutionAssetManifest) -> str: + hasher = sha256() + for rel_path in manifest.relative_paths: + asset_path = manifest.root / rel_path + mode_bits = asset_path.stat().st_mode & 0o777 + hasher.update(rel_path.encode("utf-8")) + hasher.update(b"\0") + hasher.update(f"{mode_bits:o}".encode("ascii")) + hasher.update(b"\0") + hasher.update(asset_path.read_bytes()) + hasher.update(b"\0") + return hasher.hexdigest()[:16] + + +def build_execution_assets_config( + root: Path, + declared_assets: Any = None, + *, + usage_text: str = "", + skill_name: str = "", + entrypoint: str | None = None, + metadata: Any = None, +) -> dict[str, Any]: + if _execution_assets_disabled(declared_assets): + return { + "enabled": False, + "relative_paths": [], + "digest": "", + } + + root_path = Path(root).resolve() + declared_entrypoint = resolve_execution_entrypoint( + entrypoint=entrypoint, + metadata=metadata, + ) + referenced_paths = _existing_referenced_execution_paths( + root_path, + discover_execution_asset_references( + usage_text=usage_text, + skill_name=skill_name, + ), + ) + declared_relative_paths = parse_declared_execution_assets(declared_assets) + if declared_relative_paths is None: + base_manifest = build_execution_asset_manifest(root_path, None) + merged_relative_paths = _merge_relative_paths( + list(base_manifest.relative_paths), + referenced_paths, + [declared_entrypoint] if declared_entrypoint else [], + ) + manifest = build_execution_asset_manifest( + root_path, + merged_relative_paths or None, + ) + else: + merged_declared_paths = _merge_relative_paths( + declared_relative_paths, + referenced_paths, + [declared_entrypoint] if declared_entrypoint else [], + ) + manifest = build_execution_asset_manifest( + root_path, + merged_declared_paths or None, + ) + if not manifest.relative_paths: + return { + "enabled": False, + "relative_paths": [], + "digest": "", + } + config = { + "enabled": True, + "relative_paths": list(manifest.relative_paths), + "digest": compute_execution_asset_digest(manifest), + } + normalized_entrypoint = _resolve_entrypoint_for_manifest( + root=manifest.root, + manifest=manifest, + declared_entrypoint=declared_entrypoint, + referenced_paths=referenced_paths, + ) + if normalized_entrypoint: + config["entrypoint"] = normalized_entrypoint + return config + + +def _execution_assets_disabled(raw: Any) -> bool: + if isinstance(raw, str): + text = raw.strip() + if not text: + return False + try: + parsed = json.loads(text) + except json.JSONDecodeError: + return False + return _execution_assets_disabled(parsed) + + return isinstance(raw, dict) and raw.get("enabled") is False + + +def merge_execution_assets_configs( + root: Path, + *configs: dict[str, Any], +) -> dict[str, Any]: + enabled = False + relative_paths: list[str] = [] + entrypoint: str | None = None + digest: str = "" + + for config in configs: + if not isinstance(config, dict): + continue + enabled = enabled or bool(config.get("enabled")) + relative_paths = _merge_relative_paths( + relative_paths, + [str(path).strip() for path in config.get("relative_paths", []) or [] if str(path).strip()], + ) + if not entrypoint: + candidate = config.get("entrypoint") + if isinstance(candidate, str) and candidate.strip(): + entrypoint = candidate.strip() + if not digest: + candidate_digest = str(config.get("digest", "") or "").strip() + if candidate_digest: + digest = candidate_digest + + if not enabled and not relative_paths: + return { + "enabled": False, + "relative_paths": [], + "digest": "", + } + + root_path = Path(root) + if not root_path.exists(): + merged = { + "enabled": True, + "relative_paths": relative_paths, + "digest": digest, + } + if entrypoint: + merged["entrypoint"] = entrypoint + return merged + + return build_execution_assets_config( + root_path, + declared_assets=relative_paths, + entrypoint=entrypoint, + ) + + +def build_skill_path_aliases( + *, + skill_name: str, + usage_text: str = "", +) -> list[str]: + normalized_skill_name = skill_name.strip() + if not normalized_skill_name: + return [] + + aliases = [ + f"/skills/{normalized_skill_name}", + f".claude/skills/{normalized_skill_name}", + f"./.claude/skills/{normalized_skill_name}", + f"~/.claude/skills/{normalized_skill_name}", + ] + seen = set(aliases) + + if not usage_text: + return aliases + + for match in _SKILL_PATH_ALIAS_RE.finditer(usage_text): + if match.group("skill_name") != normalized_skill_name: + continue + alias = f"{match.group('root')}/{normalized_skill_name}" + if alias in seen: + continue + aliases.append(alias) + seen.add(alias) + + return aliases + + +def resolve_execution_entrypoint( + *, + entrypoint: str | None = None, + metadata: Any = None, +) -> str | None: + for candidate in ( + entrypoint, + metadata.get("entrypoint") if isinstance(metadata, dict) else None, + ): + if isinstance(candidate, str) and candidate.strip(): + return str(Path(candidate.strip())) + return None + + +def discover_execution_asset_references( + *, + usage_text: str, + skill_name: str = "", +) -> list[str]: + if not usage_text: + return [] + + references: list[str] = [] + seen: set[str] = set() + normalized_skill_name = skill_name.strip() + + for match in _SCRIPT_REFERENCE_RE.finditer(usage_text): + candidate = _normalize_reference_path(match.group("path")) + if candidate and candidate not in seen: + references.append(candidate) + seen.add(candidate) + + for match in _RELATIVE_REFERENCE_RE.finditer(usage_text): + candidate = _normalize_reference_path(match.group("path")) + if candidate and candidate not in seen: + references.append(candidate) + seen.add(candidate) + + for match in _SKILL_VIRTUAL_REFERENCE_RE.finditer(usage_text): + if normalized_skill_name and match.group("skill_name") != normalized_skill_name: + continue + candidate = _normalize_reference_path(match.group("relative_path")) + if candidate and candidate not in seen: + references.append(candidate) + seen.add(candidate) + + return references + + +def _normalize_declared_paths(root: Path, declared_assets: list[str]) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for raw_path in declared_assets: + rel_path = str(Path(raw_path)).strip() + if not rel_path: + continue + asset_path = (root / rel_path).resolve() + try: + normalized_rel_path = str(asset_path.relative_to(root)) + except ValueError as exc: + raise ValueError(f"Execution asset path escapes skill root: {raw_path}") from exc + if not asset_path.is_file(): + raise FileNotFoundError(f"Execution asset file not found: {raw_path}") + if normalized_rel_path in seen: + continue + normalized.append(normalized_rel_path) + seen.add(normalized_rel_path) + normalized.sort() + return normalized + + +def _collect_default_execution_assets(root: Path) -> list[str]: + candidates: list[str] = [] + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + if path.name in _UNDERSTANDING_FILES: + continue + relative_path = path.relative_to(root) + if not _is_execution_asset_candidate(path, relative_path): + continue + candidates.append(str(relative_path)) + return candidates + + +def _is_default_script_path(relative_path: Path) -> bool: + parts = relative_path.parts + return bool(parts) and parts[0] == "scripts" + + +def _is_execution_asset_candidate(path: Path, relative_path: Path) -> bool: + if _is_default_script_path(relative_path): + return True + if path.suffix.lower() in _DEFAULT_EXECUTION_SUFFIXES: + return True + return _is_executable_file(path) + + +def _is_executable_file(path: Path) -> bool: + try: + return bool(path.stat().st_mode & 0o111) + except OSError: + return False + + +def _merge_relative_paths(*groups: list[str]) -> list[str]: + merged: list[str] = [] + seen: set[str] = set() + for group in groups: + for value in group: + candidate = str(Path(value)).strip() + if not candidate or candidate in seen: + continue + merged.append(candidate) + seen.add(candidate) + return merged + + +def _normalize_reference_path(raw_path: str) -> str | None: + candidate = raw_path.strip().rstrip("`'\"),.:;!?") + if not candidate: + return None + return str(Path(candidate)) + + +def _resolve_entrypoint_for_manifest( + *, + root: Path, + manifest: ExecutionAssetManifest, + declared_entrypoint: str | None, + referenced_paths: list[str], +) -> str | None: + if declared_entrypoint: + normalized = _normalize_declared_paths(root, [declared_entrypoint])[0] + if normalized not in manifest.relative_paths: + raise FileNotFoundError(f"Execution entrypoint file not found: {declared_entrypoint}") + return normalized + + referenced_script_paths = [ + reference + for reference in referenced_paths + if reference.startswith("scripts/") + ] + existing = [ + normalized + for normalized in _existing_relative_paths(root, referenced_script_paths) + if normalized in manifest.relative_paths + ] + if len(existing) == 1: + return existing[0] + return None + + +def _existing_relative_paths(root: Path, candidates: list[str]) -> list[str]: + existing: list[str] = [] + for candidate in candidates: + path = (root / candidate).resolve() + try: + normalized = str(path.relative_to(root)) + except ValueError: + continue + if path.is_file(): + existing.append(normalized) + return existing + + +def _existing_referenced_execution_paths(root: Path, candidates: list[str]) -> list[str]: + return _existing_relative_paths(root, candidates) diff --git a/aworld/skills/filesystem_provider.py b/aworld/skills/filesystem_provider.py index 681246b52..d6c6c88e5 100644 --- a/aworld/skills/filesystem_provider.py +++ b/aworld/skills/filesystem_provider.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import Any +from aworld.skills.execution_assets import build_execution_assets_config from aworld.skills.models import SkillContent, SkillDescriptor from aworld.skills.providers import SkillProvider, read_front_matter_lines from aworld.utils.skill_loader import ( @@ -92,6 +93,13 @@ def list_descriptors(self) -> list[SkillDescriptor]: "active": str(front_matter.get("active", "False")).lower() == "true", "tool_list": dict(tool_list), }, + execution_assets=build_execution_assets_config( + skill_file.parent, + declared_assets=front_matter.get("execution_assets"), + skill_name=skill_name, + entrypoint=front_matter.get("entrypoint"), + metadata=front_matter.get("metadata"), + ), requirements=requirements, ) ) @@ -116,6 +124,14 @@ def load_content(self, skill_id: str) -> SkillContent: usage=usage, tool_list=tool_list, raw_frontmatter=front_matter, + execution_assets=build_execution_assets_config( + skill_file.parent, + declared_assets=front_matter.get("execution_assets"), + usage_text=usage, + skill_name=skill_file.parent.name, + entrypoint=front_matter.get("entrypoint"), + metadata=front_matter.get("metadata"), + ), ) def resolve_asset_path(self, skill_id: str, relative_path: str) -> Path: diff --git a/aworld/skills/models.py b/aworld/skills/models.py index 893ef57b8..e57292889 100644 --- a/aworld/skills/models.py +++ b/aworld/skills/models.py @@ -17,6 +17,7 @@ class SkillDescriptor: asset_root: str skill_file: str metadata: Mapping[str, Any] = field(default_factory=dict) + execution_assets: Mapping[str, Any] = field(default_factory=dict) requirements: Mapping[str, Any] = field(default_factory=dict) @@ -26,3 +27,4 @@ class SkillContent: usage: str tool_list: Mapping[str, Any] raw_frontmatter: Mapping[str, Any] + execution_assets: Mapping[str, Any] = field(default_factory=dict) diff --git a/aworld/skills/plugin_provider.py b/aworld/skills/plugin_provider.py index 59b7181d7..ed3d3f7c4 100644 --- a/aworld/skills/plugin_provider.py +++ b/aworld/skills/plugin_provider.py @@ -3,6 +3,7 @@ from pathlib import Path from typing import Any +from aworld.skills.execution_assets import build_execution_assets_config from aworld.skills.models import SkillContent, SkillDescriptor from aworld.skills.providers import SkillProvider, read_front_matter_lines from aworld.utils.skill_loader import ( @@ -41,6 +42,13 @@ def _load_front_matter(self, skill_file: Path) -> dict[str, Any]: front_matter, _ = extract_front_matter(content) return front_matter + def _resolve_entrypoint_metadata(self, skill_id: str) -> dict[str, Any]: + entrypoint_id = skill_id.split(":", 1)[1] if ":" in skill_id else skill_id + for entrypoint in self._plugin.manifest.entrypoints.get("skills", ()): + if entrypoint.entrypoint_id == entrypoint_id: + return dict(entrypoint.metadata or {}) + return {} + def list_descriptors(self) -> list[SkillDescriptor]: descriptors: list[SkillDescriptor] = [] for entrypoint in self._plugin.manifest.entrypoints.get("skills", ()): @@ -73,6 +81,11 @@ def list_descriptors(self) -> list[SkillDescriptor]: str(front_matter.get("active", "False")).lower() == "true", ) metadata.setdefault("tool_list", dict(tool_list)) + declared_assets = ( + metadata.get("execution_assets") + if "execution_assets" in metadata + else front_matter.get("execution_assets") + ) descriptors.append( SkillDescriptor( @@ -90,6 +103,13 @@ def list_descriptors(self) -> list[SkillDescriptor]: asset_root=str(skill_file.parent.resolve()), skill_file=str(skill_file), metadata=metadata, + execution_assets=build_execution_assets_config( + skill_file.parent, + declared_assets=declared_assets, + skill_name=entrypoint.entrypoint_id, + entrypoint=front_matter.get("entrypoint"), + metadata=metadata if isinstance(metadata, dict) else front_matter.get("metadata"), + ), requirements=requirements, ) ) @@ -99,17 +119,32 @@ def load_content(self, skill_id: str) -> SkillContent: skill_file = self._skill_files.get(skill_id) if skill_file is None: raise KeyError(skill_id) + skill_name = skill_id.split(":", 1)[1] if ":" in skill_id else skill_file.parent.name + metadata = self._resolve_entrypoint_metadata(skill_id) content = skill_file.read_text(encoding="utf-8").splitlines() front_matter, body_start = extract_front_matter(content) usage = "\n".join(content[body_start:]).strip() tool_list = front_matter.get("tool_list", {}) if isinstance(tool_list, str): tool_list = {} + declared_assets = ( + metadata.get("execution_assets") + if "execution_assets" in metadata + else front_matter.get("execution_assets") + ) return SkillContent( skill_id=skill_id, usage=usage, tool_list=tool_list, raw_frontmatter=front_matter, + execution_assets=build_execution_assets_config( + skill_file.parent, + declared_assets=declared_assets, + usage_text=usage, + skill_name=skill_name, + entrypoint=front_matter.get("entrypoint"), + metadata=metadata if isinstance(metadata, dict) and metadata else front_matter.get("metadata"), + ), ) def resolve_asset_path(self, skill_id: str, relative_path: str) -> Path: diff --git a/aworld/skills/registry.py b/aworld/skills/registry.py index f977de96e..598575541 100644 --- a/aworld/skills/registry.py +++ b/aworld/skills/registry.py @@ -2,6 +2,10 @@ from typing import Any +from aworld.skills.execution_assets import ( + build_skill_path_aliases, + merge_execution_assets_configs, +) from aworld.skills.models import SkillContent, SkillDescriptor @@ -51,6 +55,15 @@ def build_skill_config( ), "skill_path": descriptor.skill_file, "asset_root": descriptor.asset_root, + "path_aliases": build_skill_path_aliases( + skill_name=descriptor.skill_name, + usage_text=content.usage, + ), + "execution_assets": merge_execution_assets_configs( + descriptor.asset_root, + dict(descriptor.execution_assets or {}), + dict(content.execution_assets or {}), + ), } if descriptor.requirements: skill_config["aworld_metadata"] = dict(descriptor.requirements) diff --git a/aworld/utils/skill_loader.py b/aworld/utils/skill_loader.py index 3afe6623a..70a766687 100644 --- a/aworld/utils/skill_loader.py +++ b/aworld/utils/skill_loader.py @@ -13,6 +13,8 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Union from urllib.parse import urlparse + +import yaml from aworld.logs.util import logger # Default cache directory for GitHub repositories @@ -370,23 +372,7 @@ def evaluate_skill_requirements(aworld_meta: Dict[str, Any]) -> Tuple[bool, Dict return eligible, missing -def extract_front_matter(content_lines: List[str]) -> Tuple[Dict[str, Any], int]: - """ - Extract YAML-like front matter from the provided content lines. - - Args: - content_lines (List[str]): The content of the markdown file split into lines. - - Returns: - Tuple[Dict[str, Any], int]: A dictionary containing the parsed front matter key-value pairs - and the index where the front matter ends. Values can be strings or parsed JSON objects. - - Example: - >>> extract_front_matter(["---", "name: sample", "---", "body"]) - ({'name': 'sample'}, 3) - >>> extract_front_matter(["---", "name: sample", 'tool_list: {"ms-playwright": []}', "---", "body"]) - ({'name': 'sample', 'tool_list': {'ms-playwright': []}}, 4) - """ +def _extract_front_matter_legacy(content_lines: List[str]) -> Tuple[Dict[str, Any], int]: front_matter: Dict[str, Any] = {} if not content_lines or content_lines[0].strip() != "---": return front_matter, 0 @@ -441,6 +427,46 @@ def extract_front_matter(content_lines: List[str]) -> Tuple[Dict[str, Any], int] return front_matter, end_index + 1 +def extract_front_matter(content_lines: List[str]) -> Tuple[Dict[str, Any], int]: + """ + Extract YAML front matter from the provided content lines. + + Args: + content_lines (List[str]): The content of the markdown file split into lines. + + Returns: + Tuple[Dict[str, Any], int]: A dictionary containing the parsed front matter key-value pairs + and the index where the front matter ends. + + Example: + >>> extract_front_matter(["---", "name: sample", "---", "body"]) + ({'name': 'sample'}, 3) + >>> extract_front_matter(["---", "name: sample", 'tool_list: {"ms-playwright": []}', "---", "body"]) + ({'name': 'sample', 'tool_list': {'ms-playwright': []}}, 4) + """ + if not content_lines or content_lines[0].strip() != "---": + return {}, 0 + + end_index = 1 + while end_index < len(content_lines) and content_lines[end_index].strip() != "---": + end_index += 1 + + if end_index >= len(content_lines): + return _extract_front_matter_legacy(content_lines) + + front_matter_block = "\n".join(content_lines[1:end_index]) + try: + parsed = yaml.safe_load(front_matter_block) or {} + except yaml.YAMLError as exc: + logger.warning(f"⚠️ Failed to parse front matter as YAML: {exc}, falling back to legacy parser") + return _extract_front_matter_legacy(content_lines) + + if not isinstance(parsed, dict): + return {}, end_index + 1 + + return parsed, end_index + 1 + + def collect_skill_docs( root_path: Union[str, Path], cache_dir: Optional[Path] = None diff --git a/tests/config/test_task_loader.py b/tests/config/test_task_loader.py index 3d711c70d..c1f1a5854 100644 --- a/tests/config/test_task_loader.py +++ b/tests/config/test_task_loader.py @@ -43,7 +43,29 @@ async def test_load_skill_agent_uses_descriptor_lookup_before_loading_content( agent = await _load_skill_agent( agent_id="planner-agent", - agent_def={"skill_name": "planner", "config": {}}, + agent_def={ + "skill_name": "planner", + "config": { + "skill_configs": { + "browser-use": { + "name": "browser-use", + "description": "Browser automation", + "tool_list": {"browser": {"desc": "Browser MCP"}}, + "usage": "Use browser skill.", + "type": "agent", + "active": False, + "skill_path": "/tmp/browser/SKILL.md", + "asset_root": "/tmp/browser", + "path_aliases": ["/skills/browser-use"], + "execution_assets": { + "enabled": True, + "relative_paths": ["run.sh"], + "digest": "feed1234feed1234", + }, + } + } + }, + }, skills_path=skills_root, global_mcp_config=None, ) @@ -52,3 +74,11 @@ async def test_load_skill_agent_uses_descriptor_lookup_before_loading_content( assert agent.desc() == "Planning skill" assert "browser" in agent.mcp_servers assert "Use this skill for planning." in agent.system_prompt + assert "browser-use" in agent.conf.skill_configs + assert agent.conf.skill_configs["planner"]["asset_root"] == str(good_skill_dir.resolve()) + assert agent.conf.skill_configs["planner"]["execution_assets"]["enabled"] is False + assert agent.conf.skill_configs["browser-use"]["asset_root"] == "/tmp/browser" + assert agent.skill_configs["planner"]["asset_root"] == str(good_skill_dir.resolve()) + assert agent.skill_configs["browser-use"]["asset_root"] == "/tmp/browser" + assert agent.sandbox.skill_configs["planner"]["asset_root"] == str(good_skill_dir.resolve()) + assert agent.sandbox.skill_configs["browser-use"]["asset_root"] == "/tmp/browser" diff --git a/tests/core/context/amni/services/test_skill_service.py b/tests/core/context/amni/services/test_skill_service.py index f2c867fc9..a98733439 100644 --- a/tests/core/context/amni/services/test_skill_service.py +++ b/tests/core/context/amni/services/test_skill_service.py @@ -37,6 +37,11 @@ async def test_skill_service_keeps_inactive_skill_usage_out_of_compat_list() -> "usage": "Use browser tools", "tool_list": {"browser": {}}, "skill_path": "/tmp/browser-use/SKILL.md", + "execution_assets": { + "enabled": True, + "relative_paths": ["run.sh"], + "digest": "abcd1234abcd1234", + }, "active": False, }, "code-review": { @@ -45,6 +50,11 @@ async def test_skill_service_keeps_inactive_skill_usage_out_of_compat_list() -> "usage": "Review code carefully", "tool_list": {"shell": {}}, "skill_path": "/tmp/code-review/SKILL.md", + "execution_assets": { + "enabled": True, + "relative_paths": ["review.py"], + "digest": "dcba4321dcba4321", + }, "active": True, }, }, @@ -57,6 +67,7 @@ async def test_skill_service_keeps_inactive_skill_usage_out_of_compat_list() -> assert active_skills == ["code-review"] assert all_skills["browser-use"].get("usage", "") == "" assert all_skills["browser-use"].get("tool_list", {}) == {} + assert all_skills["browser-use"]["execution_assets"]["relative_paths"] == ["run.sh"] assert all_skills["code-review"]["usage"] == "Review code carefully" @@ -73,6 +84,11 @@ async def test_skill_service_loads_inactive_skill_content_on_first_get() -> None "usage": "Use browser tools", "tool_list": {"browser": {}}, "skill_path": "/tmp/browser-use/SKILL.md", + "execution_assets": { + "enabled": True, + "relative_paths": ["run.sh"], + "digest": "abcd1234abcd1234", + }, "active": False, } }, @@ -84,7 +100,9 @@ async def test_skill_service_loads_inactive_skill_content_on_first_get() -> None after = await service.get_skill_list("agent-1") assert before["browser-use"].get("usage", "") == "" + assert before["browser-use"]["execution_assets"]["relative_paths"] == ["run.sh"] assert skill["usage"] == "Use browser tools" + assert skill["execution_assets"]["relative_paths"] == ["run.sh"] assert after["browser-use"]["usage"] == "Use browser tools" @@ -107,3 +125,37 @@ async def init_skill_list(self, skill_list, namespace): ) assert calls == [({"browser-use": {"name": "browser-use"}}, "agent-1")] + + +@pytest.mark.asyncio +async def test_skill_service_does_not_validate_execution_assets_during_host_side_staging() -> None: + context = _FakeContext() + service = SkillService(context) + + await service.init_skill_list( + { + "browser-use": { + "name": "browser-use", + "description": "Browser automation", + "usage": "Use browser tools", + "tool_list": {"browser": {}}, + "skill_path": "/tmp/browser-use/SKILL.md", + "execution_assets": { + "enabled": True, + "relative_paths": ["missing.py"], + "digest": "badbadbadbadbadb", + "entrypoint": "missing.py", + }, + "active": False, + } + }, + namespace="agent-1", + ) + + descriptor_view = await service.get_skill_list("agent-1") + loaded_skill = await service.get_skill("browser-use", "agent-1") + + assert descriptor_view["browser-use"]["execution_assets"]["entrypoint"] == "missing.py" + assert descriptor_view["browser-use"].get("usage", "") == "" + assert loaded_skill["usage"] == "Use browser tools" + assert loaded_skill["execution_assets"]["relative_paths"] == ["missing.py"] diff --git a/tests/core/test_skill_activation_resolver.py b/tests/core/test_skill_activation_resolver.py index 708eb6c66..7e83e158d 100644 --- a/tests/core/test_skill_activation_resolver.py +++ b/tests/core/test_skill_activation_resolver.py @@ -193,3 +193,26 @@ def test_resolver_filters_disabled_skill_names(tmp_path: Path) -> None: ) assert "youtube_search" not in result.skill_configs + + +def test_resolver_preserves_plugin_execution_entrypoint_metadata(tmp_path: Path) -> None: + plugin_root = _write_manifest_skill_plugin( + tmp_path, + plugin_id="swarm-tools", + skill_id="swarm", + metadata={"entrypoint": "scripts/index.ts"}, + ) + scripts_dir = plugin_root / "skills" / "swarm" / "scripts" + scripts_dir.mkdir(parents=True) + (scripts_dir / "index.ts").write_text("export {};\n", encoding="utf-8") + + result = SkillActivationResolver().resolve( + SkillResolverRequest( + plugin_roots=(plugin_root,), + runtime_scope="workspace", + agent_name="developer", + requested_skill_names=("swarm",), + ) + ) + + assert result.skill_configs["swarm"]["execution_assets"]["entrypoint"] == "scripts/index.ts" diff --git a/tests/core/test_skill_runtime_resolution.py b/tests/core/test_skill_runtime_resolution.py index 6a5577d56..4cfc652fe 100644 --- a/tests/core/test_skill_runtime_resolution.py +++ b/tests/core/test_skill_runtime_resolution.py @@ -124,9 +124,11 @@ def test_resolver_builds_skill_configs_from_framework_registry(tmp_path: Path) - skill_dir = tmp_path / "skills" / "browser-use" skill_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( - "---\ndescription: Browser automation\n---\n\n# Usage\nUse browser tools.\n", + "---\ndescription: Browser automation\nentrypoint: scripts/run.sh\n---\n\n# Usage\nUse browser tools.\n", encoding="utf-8", ) + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "run.sh").write_text("echo browser\n", encoding="utf-8") request = SkillResolverRequest( plugin_roots=(), @@ -140,3 +142,35 @@ def test_resolver_builds_skill_configs_from_framework_registry(tmp_path: Path) - assert "browser-use" in resolved.skill_configs assert resolved.skill_configs["browser-use"]["description"] == "Browser automation" assert resolved.skill_configs["browser-use"]["asset_root"] == str(skill_dir.resolve()) + assert resolved.skill_configs["browser-use"]["execution_assets"]["enabled"] is True + assert resolved.skill_configs["browser-use"]["execution_assets"]["relative_paths"] == ["scripts/run.sh"] + assert resolved.skill_configs["browser-use"]["execution_assets"]["entrypoint"] == "scripts/run.sh" + + +def test_resolver_builds_skill_configs_from_nested_metadata_entrypoint(tmp_path: Path) -> None: + skill_dir = tmp_path / "skills" / "browser-use" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + ( + "---\n" + "description: Browser automation\n" + "metadata:\n" + " entrypoint: scripts/index.ts\n" + "---\n\n" + "# Usage\nUse browser tools.\n" + ), + encoding="utf-8", + ) + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "index.ts").write_text("console.log('browser');\n", encoding="utf-8") + + request = SkillResolverRequest( + plugin_roots=(), + runtime_scope="session", + agent_name="Aworld", + compatibility_sources=(str(tmp_path / "skills"),), + ) + + resolved = SkillActivationResolver().resolve(request) + + assert resolved.skill_configs["browser-use"]["execution_assets"]["entrypoint"] == "scripts/index.ts" diff --git a/tests/sandbox/test_mcp_servers_content.py b/tests/sandbox/test_mcp_servers_content.py index f784efaf4..bfcb2144e 100644 --- a/tests/sandbox/test_mcp_servers_content.py +++ b/tests/sandbox/test_mcp_servers_content.py @@ -1,4 +1,7 @@ +import pytest + from aworld.sandbox.run.mcp_servers import ( + McpServers, _build_tool_call_failure_result, _coalesce_tool_result_content, ) @@ -30,3 +33,84 @@ def test_build_tool_call_failure_result_includes_error_context_and_parameter_sum assert "RuntimeError: boom" in result.content assert "command=python script.py" in result.content assert "timeout=30" in result.content + + +def _terminal_tool(tool_name: str, param_name: str) -> dict[str, object]: + return { + "type": "function", + "function": { + "name": f"terminal__{tool_name}", + "parameters": { + "type": "object", + "properties": { + param_name: { + "type": "string", + } + }, + }, + }, + } + + +class _FailingSyncSandbox: + def __init__(self) -> None: + self.mode = "remote" + self.sandbox_id = None + self.reuse = False + self.env_content_name = None + + async def ensure_skill_execution_assets_ready( + self, + skill_name: str, + skill_config: dict[str, object], + ) -> str: + raise RuntimeError(f"sync failed for {skill_name}") + + +@pytest.mark.asyncio +async def test_call_tool_surfaces_remote_sync_failure_before_terminal_execution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + executed = {"called": False} + + async def _unexpected_call(**kwargs): + executed["called"] = True + return None + + monkeypatch.setattr( + "aworld.sandbox.run.mcp_servers.call_mcp_tool_with_exit_stack", + _unexpected_call, + ) + + servers = McpServers( + mcp_servers=["terminal"], + mcp_config={"mcpServers": {"terminal": {}}}, + sandbox=_FailingSyncSandbox(), + skill_configs={ + "browser-use": { + "asset_root": "/host/skills/browser-use", + "execution_assets": { + "enabled": True, + "relative_paths": ["scripts/run.py"], + "digest": "feed1234feed1234", + }, + } + }, + ) + servers.tool_list = [_terminal_tool("run_code", "code")] + + results = await servers.call_tool( + action_list=[ + { + "tool_name": "terminal", + "action_name": "run_code", + "params": {"code": "python /skills/browser-use/scripts/run.py"}, + } + ], + context=None, + ) + + assert executed["called"] is False + assert results is not None + assert len(results) == 1 + assert "sync failed for browser-use" in results[0].content diff --git a/tests/sandbox/test_mcp_servers_skill_paths.py b/tests/sandbox/test_mcp_servers_skill_paths.py new file mode 100644 index 000000000..494a33995 --- /dev/null +++ b/tests/sandbox/test_mcp_servers_skill_paths.py @@ -0,0 +1,774 @@ +from pathlib import Path + +import pytest + +from aworld.sandbox.run.mcp_servers import McpServers + + +class _FakeSandbox: + def __init__( + self, + *, + mode: str = "remote", + remote_workspace_root: str = "/remote/workspace", + ) -> None: + self.mode = mode + self.remote_workspace_root = remote_workspace_root + self.sandbox_id = None + self.reuse = False + self.calls: list[tuple[str, dict[str, object]]] = [] + + async def ensure_skill_execution_assets_ready( + self, + skill_name: str, + skill_config: dict[str, object], + ) -> str: + self.calls.append((skill_name, skill_config)) + digest = skill_config["execution_assets"]["digest"] + return f"{self.remote_workspace_root}/.aworld/skills/{skill_name}/{digest}" + + +class _FakeContext: + def __init__( + self, + active_skills: list[str], + *, + namespace: str | None = None, + ) -> None: + self._active_skills = active_skills + self.agent_info = type("AgentInfo", (), {"current_agent_id": namespace})() + self.last_namespace: str | None = None + + async def get_active_skills(self, namespace: str | None = None): + self.last_namespace = namespace + return list(self._active_skills) + + +def _terminal_tool(tool_name: str, param_name: str) -> dict[str, object]: + return { + "type": "function", + "function": { + "name": f"terminal__{tool_name}", + "parameters": { + "type": "object", + "properties": { + param_name: { + "type": "string", + } + }, + }, + }, + } + + +def _generic_tool(server_name: str, tool_name: str, param_name: str) -> dict[str, object]: + return { + "type": "function", + "function": { + "name": f"{server_name}__{tool_name}", + "parameters": { + "type": "object", + "properties": { + param_name: { + "type": "string", + } + }, + }, + }, + } + + +@pytest.mark.asyncio +async def test_check_tool_params_rewrites_remote_run_code_skill_paths( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + run_file = skill_root / "run.py" + run_file.write_text("print('hi')\n", encoding="utf-8") + + skill_config = { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["run.py"], + "digest": "abcd1234abcd1234", + "entrypoint": "run.py", + }, + } + sandbox = _FakeSandbox(mode="remote") + servers = McpServers( + mcp_servers=["terminal"], + mcp_config={"mcpServers": {"terminal": {}}}, + sandbox=sandbox, + skill_configs={"browser-use": skill_config}, + ) + servers.tool_list = [_terminal_tool("run_code", "code")] + parameter = {"code": f"cd {skill_root} && python {run_file}"} + + ok = await servers.check_tool_params( + context=None, + server_name="terminal", + tool_name="run_code", + parameter=parameter, + ) + + assert ok is True + assert parameter["code"] == ( + "cd /remote/workspace/.aworld/skills/browser-use/abcd1234abcd1234" + " && python /remote/workspace/.aworld/skills/browser-use/abcd1234abcd1234/run.py" + ) + assert sandbox.calls == [("browser-use", skill_config)] + + +@pytest.mark.asyncio +async def test_check_tool_params_rewrites_remote_paths_for_terminal_server_alias( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + run_file = skill_root / "run.py" + run_file.write_text("print('hi')\n", encoding="utf-8") + + skill_config = { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["run.py"], + "digest": "a1b2c3d4a1b2c3d4", + "entrypoint": "run.py", + }, + } + sandbox = _FakeSandbox(mode="remote") + servers = McpServers( + mcp_servers=["terminal-server"], + mcp_config={"mcpServers": {"terminal-server": {}}}, + sandbox=sandbox, + skill_configs={"browser-use": skill_config}, + ) + servers.tool_list = [_terminal_tool("run_code", "code")] + servers.tool_list[0]["function"]["name"] = "terminal-server__run_code" + parameter = {"code": f"python {run_file}"} + + ok = await servers.check_tool_params( + context=None, + server_name="terminal-server", + tool_name="run_code", + parameter=parameter, + ) + + assert ok is True + assert ( + parameter["code"] + == "python /remote/workspace/.aworld/skills/browser-use/a1b2c3d4a1b2c3d4/run.py" + ) + assert sandbox.calls == [("browser-use", skill_config)] + + +@pytest.mark.asyncio +async def test_check_tool_params_rewrites_paths_for_secondary_terminal_alias_when_canonical_exists( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + run_file = skill_root / "run.py" + run_file.write_text("print('hi')\n", encoding="utf-8") + + skill_config = { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["run.py"], + "digest": "1111aaaa1111aaaa", + "entrypoint": "run.py", + }, + } + sandbox = _FakeSandbox(mode="remote") + servers = McpServers( + mcp_servers=["terminal", "terminal-server"], + mcp_config={"mcpServers": {"terminal": {}, "terminal-server": {}}}, + sandbox=sandbox, + skill_configs={"browser-use": skill_config}, + ) + servers.tool_list = [_terminal_tool("run_code", "code")] + servers.tool_list[0]["function"]["name"] = "terminal-server__run_code" + parameter = {"code": f"python {run_file}"} + + ok = await servers.check_tool_params( + context=None, + server_name="terminal-server", + tool_name="run_code", + parameter=parameter, + ) + + assert ok is True + assert ( + parameter["code"] + == "python /remote/workspace/.aworld/skills/browser-use/1111aaaa1111aaaa/run.py" + ) + assert sandbox.calls == [("browser-use", skill_config)] + + +@pytest.mark.asyncio +async def test_check_tool_params_quotes_rewritten_remote_paths_with_spaces( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + run_file = skill_root / "run.py" + run_file.write_text("print('hi')\n", encoding="utf-8") + + skill_config = { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["run.py"], + "digest": "abcd1234abcd1234", + "entrypoint": "run.py", + }, + } + sandbox = _FakeSandbox(mode="remote", remote_workspace_root="/remote/My Project") + servers = McpServers( + mcp_servers=["terminal"], + mcp_config={"mcpServers": {"terminal": {}}}, + sandbox=sandbox, + skill_configs={"browser-use": skill_config}, + ) + servers.tool_list = [_terminal_tool("run_code", "code")] + parameter = { + "code": f"cd {skill_root} && python {run_file} && python /skills/browser-use/run.py" + } + + ok = await servers.check_tool_params( + context=None, + server_name="terminal", + tool_name="run_code", + parameter=parameter, + ) + + assert ok is True + assert parameter["code"] == ( + "cd '/remote/My Project/.aworld/skills/browser-use/abcd1234abcd1234'" + " && python '/remote/My Project/.aworld/skills/browser-use/abcd1234abcd1234/run.py'" + " && python '/remote/My Project/.aworld/skills/browser-use/abcd1234abcd1234'/run.py" + ) + assert sandbox.calls == [("browser-use", skill_config)] + + +@pytest.mark.asyncio +async def test_check_tool_params_uses_windows_safe_quotes_for_rewritten_remote_paths( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + run_file = skill_root / "run.py" + run_file.write_text("print('hi')\n", encoding="utf-8") + + skill_config = { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["run.py"], + "digest": "abcd1234abcd1234", + "entrypoint": "run.py", + }, + } + sandbox = _FakeSandbox(mode="remote", remote_workspace_root=r"C:\remote\My Project") + servers = McpServers( + mcp_servers=["terminal"], + mcp_config={"mcpServers": {"terminal": {}}}, + sandbox=sandbox, + skill_configs={"browser-use": skill_config}, + ) + servers.tool_list = [_terminal_tool("run_code", "code")] + parameter = {"code": "python /skills/browser-use/run.py"} + + ok = await servers.check_tool_params( + context=None, + server_name="terminal", + tool_name="run_code", + parameter=parameter, + ) + + assert ok is True + assert parameter["code"] == ( + 'python "C:\\remote\\My Project/.aworld/skills/browser-use/abcd1234abcd1234"/run.py' + ) + assert sandbox.calls == [("browser-use", skill_config)] + + +@pytest.mark.asyncio +async def test_check_tool_params_leaves_local_mode_skill_paths_unchanged( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + run_file = skill_root / "run.sh" + run_file.write_text("echo hi\n", encoding="utf-8") + + skill_config = { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["run.sh"], + "digest": "dcba4321dcba4321", + "entrypoint": "run.sh", + }, + } + sandbox = _FakeSandbox(mode="local") + servers = McpServers( + mcp_servers=["terminal"], + mcp_config={"mcpServers": {"terminal": {}}}, + sandbox=sandbox, + skill_configs={"browser-use": skill_config}, + ) + servers.tool_list = [_terminal_tool("mcp_execute_command", "command")] + parameter = {"command": f"bash {run_file}"} + + ok = await servers.check_tool_params( + context=None, + server_name="terminal", + tool_name="mcp_execute_command", + parameter=parameter, + ) + + assert ok is True + assert parameter["command"] == f"bash {run_file}" + assert sandbox.calls == [] + + +@pytest.mark.asyncio +async def test_check_tool_params_rewrites_entrypoint_relative_path_for_active_skill( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + (skill_root / "scripts").mkdir() + (skill_root / "scripts" / "run.py").write_text("print('hi')\n", encoding="utf-8") + + skill_config = { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["scripts/run.py"], + "digest": "feed1234feed1234", + "entrypoint": "scripts/run.py", + }, + } + sandbox = _FakeSandbox(mode="remote") + servers = McpServers( + mcp_servers=["terminal"], + mcp_config={"mcpServers": {"terminal": {}}}, + sandbox=sandbox, + skill_configs={"browser-use": skill_config}, + ) + servers.tool_list = [_terminal_tool("run_code", "code")] + parameter = {"code": "python scripts/run.py"} + + context = _FakeContext(["browser-use"], namespace="designer-agent") + ok = await servers.check_tool_params( + context=context, + server_name="terminal", + tool_name="run_code", + parameter=parameter, + ) + + assert ok is True + assert ( + parameter["code"] + == "python /remote/workspace/.aworld/skills/browser-use/feed1234feed1234/scripts/run.py" + ) + assert context.last_namespace == "designer-agent" + assert sandbox.calls == [("browser-use", skill_config)] + + +@pytest.mark.asyncio +async def test_check_tool_params_does_not_rewrite_relative_path_without_active_skill( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + (skill_root / "scripts").mkdir() + (skill_root / "scripts" / "run.py").write_text("print('hi')\n", encoding="utf-8") + + skill_config = { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["scripts/run.py"], + "digest": "deaf1234deaf1234", + "entrypoint": "scripts/run.py", + }, + } + sandbox = _FakeSandbox(mode="remote") + servers = McpServers( + mcp_servers=["terminal"], + mcp_config={"mcpServers": {"terminal": {}}}, + sandbox=sandbox, + skill_configs={"browser-use": skill_config}, + ) + servers.tool_list = [_terminal_tool("run_code", "code")] + parameter = {"code": "python scripts/run.py"} + + ok = await servers.check_tool_params( + context=None, + server_name="terminal", + tool_name="run_code", + parameter=parameter, + ) + + assert ok is True + assert parameter["code"] == "python scripts/run.py" + assert sandbox.calls == [] + + +@pytest.mark.asyncio +async def test_check_tool_params_syncs_active_skill_before_relative_cd_command( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + (skill_root / "scripts").mkdir() + (skill_root / "scripts" / "run.py").write_text("print('hi')\n", encoding="utf-8") + + skill_config = { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["scripts/run.py"], + "digest": "f00d1234f00d1234", + "entrypoint": "scripts/run.py", + }, + } + sandbox = _FakeSandbox(mode="remote") + servers = McpServers( + mcp_servers=["terminal"], + mcp_config={"mcpServers": {"terminal": {}}}, + sandbox=sandbox, + skill_configs={"browser-use": skill_config}, + ) + servers.tool_list = [_terminal_tool("run_code", "code")] + parameter = {"code": "cd scripts && python run.py"} + + context = _FakeContext(["browser-use"], namespace="designer-agent") + ok = await servers.check_tool_params( + context=context, + server_name="terminal", + tool_name="run_code", + parameter=parameter, + ) + + assert ok is True + assert ( + parameter["code"] + == "cd /remote/workspace/.aworld/skills/browser-use/f00d1234f00d1234/scripts" + " && python run.py" + ) + assert context.last_namespace == "designer-agent" + assert sandbox.calls == [("browser-use", skill_config)] + + +@pytest.mark.asyncio +async def test_check_tool_params_rewrites_virtual_skill_path_reference( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "code-review" + skill_root.mkdir(parents=True) + lint_file = skill_root / "lint_check.py" + lint_file.write_text("print('lint')\n", encoding="utf-8") + + skill_config = { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["lint_check.py"], + "digest": "c0de1234c0de1234", + }, + } + sandbox = _FakeSandbox(mode="remote") + servers = McpServers( + mcp_servers=["terminal"], + mcp_config={"mcpServers": {"terminal": {}}}, + sandbox=sandbox, + skill_configs={"code-review": skill_config}, + ) + servers.tool_list = [_terminal_tool("mcp_execute_command", "command")] + parameter = {"command": "python /skills/code-review/lint_check.py ."} + + ok = await servers.check_tool_params( + context=None, + server_name="terminal", + tool_name="mcp_execute_command", + parameter=parameter, + ) + + assert ok is True + assert ( + parameter["command"] + == "python /remote/workspace/.aworld/skills/code-review/c0de1234c0de1234/lint_check.py ." + ) + assert sandbox.calls == [("code-review", skill_config)] + + +@pytest.mark.asyncio +async def test_check_tool_params_rewrites_legacy_claude_skill_path_reference( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "html-to-image" + skill_root.mkdir(parents=True) + render_file = skill_root / "scripts" / "render.py" + render_file.parent.mkdir() + render_file.write_text("print('render')\n", encoding="utf-8") + + skill_config = { + "asset_root": str(skill_root), + "path_aliases": [ + "/skills/html-to-image", + ".claude/skills/html-to-image", + "./.claude/skills/html-to-image", + ], + "execution_assets": { + "enabled": True, + "relative_paths": ["scripts/render.py"], + "digest": "abc12345abc12345", + }, + } + sandbox = _FakeSandbox(mode="remote") + servers = McpServers( + mcp_servers=["terminal"], + mcp_config={"mcpServers": {"terminal": {}}}, + sandbox=sandbox, + skill_configs={"html-to-image": skill_config}, + ) + servers.tool_list = [_terminal_tool("mcp_execute_command", "command")] + parameter = {"command": "python .claude/skills/html-to-image/scripts/render.py"} + + ok = await servers.check_tool_params( + context=None, + server_name="terminal", + tool_name="mcp_execute_command", + parameter=parameter, + ) + + assert ok is True + assert ( + parameter["command"] + == "python /remote/workspace/.aworld/skills/html-to-image/abc12345abc12345/scripts/render.py" + ) + assert sandbox.calls == [("html-to-image", skill_config)] + + +@pytest.mark.asyncio +async def test_check_tool_params_rewrites_legacy_claude_alias_without_declared_path_aliases( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "html-to-image" + skill_root.mkdir(parents=True) + render_file = skill_root / "scripts" / "render.py" + render_file.parent.mkdir() + render_file.write_text("print('render')\n", encoding="utf-8") + + skill_config = { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["scripts/render.py"], + "digest": "ddd12345ddd12345", + }, + } + sandbox = _FakeSandbox(mode="remote") + servers = McpServers( + mcp_servers=["terminal"], + mcp_config={"mcpServers": {"terminal": {}}}, + sandbox=sandbox, + skill_configs={"html-to-image": skill_config}, + ) + servers.tool_list = [_terminal_tool("mcp_execute_command", "command")] + parameter = {"command": "python .claude/skills/html-to-image/scripts/render.py"} + + ok = await servers.check_tool_params( + context=None, + server_name="terminal", + tool_name="mcp_execute_command", + parameter=parameter, + ) + + assert ok is True + assert ( + parameter["command"] + == "python /remote/workspace/.aworld/skills/html-to-image/ddd12345ddd12345/scripts/render.py" + ) + assert sandbox.calls == [("html-to-image", skill_config)] + + +@pytest.mark.asyncio +async def test_check_tool_params_rewrites_custom_skill_path_alias_from_skill_config( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "html-to-image" + skill_root.mkdir(parents=True) + render_file = skill_root / "scripts" / "render.py" + render_file.parent.mkdir() + render_file.write_text("print('render')\n", encoding="utf-8") + + skill_config = { + "asset_root": str(skill_root), + "path_aliases": ["vendor/skills/html-to-image"], + "execution_assets": { + "enabled": True, + "relative_paths": ["scripts/render.py"], + "digest": "bcd23456bcd23456", + }, + } + sandbox = _FakeSandbox(mode="remote") + servers = McpServers( + mcp_servers=["terminal"], + mcp_config={"mcpServers": {"terminal": {}}}, + sandbox=sandbox, + skill_configs={"html-to-image": skill_config}, + ) + servers.tool_list = [_terminal_tool("mcp_execute_command", "command")] + parameter = {"command": "python vendor/skills/html-to-image/scripts/render.py"} + + ok = await servers.check_tool_params( + context=None, + server_name="terminal", + tool_name="mcp_execute_command", + parameter=parameter, + ) + + assert ok is True + assert ( + parameter["command"] + == "python /remote/workspace/.aworld/skills/html-to-image/bcd23456bcd23456/scripts/render.py" + ) + assert sandbox.calls == [("html-to-image", skill_config)] + + +@pytest.mark.asyncio +async def test_check_tool_params_keeps_default_legacy_aliases_when_skill_config_has_custom_aliases( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "html-to-image" + skill_root.mkdir(parents=True) + render_file = skill_root / "scripts" / "render.py" + render_file.parent.mkdir() + render_file.write_text("print('render')\n", encoding="utf-8") + + skill_config = { + "asset_root": str(skill_root), + "path_aliases": ["vendor/skills/html-to-image"], + "execution_assets": { + "enabled": True, + "relative_paths": ["scripts/render.py"], + "digest": "eeee1234eeee1234", + }, + } + sandbox = _FakeSandbox(mode="remote") + servers = McpServers( + mcp_servers=["terminal"], + mcp_config={"mcpServers": {"terminal": {}}}, + sandbox=sandbox, + skill_configs={"html-to-image": skill_config}, + ) + servers.tool_list = [_terminal_tool("mcp_execute_command", "command")] + parameter = {"command": "python .claude/skills/html-to-image/scripts/render.py"} + + ok = await servers.check_tool_params( + context=None, + server_name="terminal", + tool_name="mcp_execute_command", + parameter=parameter, + ) + + assert ok is True + assert ( + parameter["command"] + == "python /remote/workspace/.aworld/skills/html-to-image/eeee1234eeee1234/scripts/render.py" + ) + assert sandbox.calls == [("html-to-image", skill_config)] + + +@pytest.mark.asyncio +async def test_check_tool_params_rewrites_tilde_claude_skill_alias( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "lennys-podcast-newsletter" + skill_root.mkdir(parents=True) + search_file = skill_root / "scripts" / "lenny_search.py" + search_file.parent.mkdir() + search_file.write_text("print('search')\n", encoding="utf-8") + + skill_config = { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["scripts/lenny_search.py"], + "digest": "tilde1234tilde1234", + }, + } + sandbox = _FakeSandbox(mode="remote") + servers = McpServers( + mcp_servers=["terminal"], + mcp_config={"mcpServers": {"terminal": {}}}, + sandbox=sandbox, + skill_configs={"lennys-podcast-newsletter": skill_config}, + ) + servers.tool_list = [_terminal_tool("mcp_execute_command", "command")] + parameter = { + "command": "SCRIPT=~/.claude/skills/lennys-podcast-newsletter/scripts/lenny_search.py && python $SCRIPT" + } + + ok = await servers.check_tool_params( + context=None, + server_name="terminal", + tool_name="mcp_execute_command", + parameter=parameter, + ) + + assert ok is True + assert ( + parameter["command"] + == "SCRIPT=/remote/workspace/.aworld/skills/lennys-podcast-newsletter/tilde1234tilde1234/scripts/lenny_search.py && python $SCRIPT" + ) + assert sandbox.calls == [("lennys-podcast-newsletter", skill_config)] + + +@pytest.mark.asyncio +async def test_check_tool_params_does_not_sync_for_non_terminal_tool_calls( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + run_file = skill_root / "run.py" + run_file.write_text("print('hi')\n", encoding="utf-8") + + skill_config = { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["run.py"], + "digest": "abcd1234abcd1234", + "entrypoint": "run.py", + }, + } + sandbox = _FakeSandbox(mode="remote") + servers = McpServers( + mcp_servers=["filesystem"], + mcp_config={"mcpServers": {"filesystem": {}}}, + sandbox=sandbox, + skill_configs={"browser-use": skill_config}, + ) + servers.tool_list = [_generic_tool("filesystem", "write_file", "content")] + parameter = {"content": f"python {run_file}"} + + ok = await servers.check_tool_params( + context=None, + server_name="filesystem", + tool_name="write_file", + parameter=parameter, + ) + + assert ok is True + assert parameter["content"] == f"python {run_file}" + assert sandbox.calls == [] diff --git a/tests/sandbox/test_skill_sync.py b/tests/sandbox/test_skill_sync.py new file mode 100644 index 000000000..3ca86f6e1 --- /dev/null +++ b/tests/sandbox/test_skill_sync.py @@ -0,0 +1,325 @@ +from pathlib import Path + +import pytest + +from aworld.sandbox.skill_sync import ensure_remote_skill_assets_ready + + +class _FakeFileNamespace: + def __init__(self, *, allowed_directories_data: str = "Allowed directories:\n/remote/workspace") -> None: + self.allowed_directories_data = allowed_directories_data + self.created: list[str] = [] + self.written: list[tuple[str, str]] = [] + self.written_base64: list[tuple[str, str]] = [] + self.uploaded: list[tuple[str, str]] = [] + + async def list_allowed_directories(self): + return { + "success": True, + "data": self.allowed_directories_data, + "error": None, + } + + async def create_directory(self, path: str): + self.created.append(path) + return {"success": True, "data": path, "error": None} + + async def write_file(self, path: str, content: str): + self.written.append((path, content)) + return {"success": True, "data": path, "error": None} + + async def write_file_base64(self, path: str, content_base64: str): + self.written_base64.append((path, content_base64)) + return {"success": True, "data": path, "error": None} + + async def upload_file(self, source_path: str, target_path: str): + self.uploaded.append((source_path, target_path)) + return {"success": True, "data": target_path, "error": None} + + +class _FakeTerminalNamespace: + def __init__(self) -> None: + self.commands: list[str] = [] + + async def run_code(self, code: str, timeout: int = 30, output_format: str = "markdown"): + self.commands.append(code) + return {"success": True, "data": "ok", "error": None} + + +class _FailingWriteFileNamespace(_FakeFileNamespace): + def __init__(self, fail_path_suffix: str) -> None: + super().__init__() + self.fail_path_suffix = fail_path_suffix + + async def write_file(self, path: str, content: str): + if path.endswith(self.fail_path_suffix): + return {"success": False, "data": None, "error": f"failed: {path}"} + return await super().write_file(path, content) + + +class _FakeSandbox: + def __init__(self, *, allowed_directories_data: str = "Allowed directories:\n/remote/workspace") -> None: + self.mode = "remote" + self.file = _FakeFileNamespace(allowed_directories_data=allowed_directories_data) + self.terminal = _FakeTerminalNamespace() + self._remote_skill_execution_roots: dict[tuple[str, str], str] = {} + self._remote_skill_execution_base_dir: str | None = None + + +class _FailingWriteSandbox(_FakeSandbox): + def __init__(self, fail_path_suffix: str) -> None: + super().__init__() + self.file = _FailingWriteFileNamespace(fail_path_suffix) + + +@pytest.mark.asyncio +async def test_ensure_remote_skill_assets_ready_writes_manifest_files( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + (skill_root / "run.py").write_text("print('hi')\n", encoding="utf-8") + (skill_root / "config.json").write_text('{"debug": true}\n', encoding="utf-8") + + sandbox = _FakeSandbox() + remote_root = await ensure_remote_skill_assets_ready( + sandbox, + "browser-use", + { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["config.json", "run.py"], + "digest": "1234abcd5678ef00", + }, + }, + ) + + assert remote_root == "/remote/workspace/.aworld/skills/browser-use/1234abcd5678ef00" + assert sandbox.file.created == [remote_root] + assert sandbox.file.written == [ + ( + f"{remote_root}/config.json", + '{"debug": true}\n', + ), + ( + f"{remote_root}/run.py", + "print('hi')\n", + ), + ] + + +@pytest.mark.asyncio +async def test_ensure_remote_skill_assets_ready_reuses_cached_root( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + (skill_root / "run.sh").write_text("echo hi\n", encoding="utf-8") + + sandbox = _FakeSandbox() + skill_config = { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["run.sh"], + "digest": "abcd1234abcd1234", + }, + } + + first = await ensure_remote_skill_assets_ready(sandbox, "browser-use", skill_config) + second = await ensure_remote_skill_assets_ready(sandbox, "browser-use", skill_config) + + assert first == second + assert sandbox.file.created == [first] + assert sandbox.file.written == [(f"{first}/run.sh", "echo hi\n")] + + +@pytest.mark.asyncio +async def test_ensure_remote_skill_assets_ready_excludes_understanding_assets( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + (skill_root / "SKILL.md").write_text("---\n---\n", encoding="utf-8") + (skill_root / "scripts").mkdir() + (skill_root / "scripts" / "run.py").write_text("print('hi')\n", encoding="utf-8") + + sandbox = _FakeSandbox() + remote_root = await ensure_remote_skill_assets_ready( + sandbox, + "browser-use", + { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["scripts/run.py"], + "digest": "bead1234bead1234", + }, + }, + ) + + assert sandbox.file.written == [ + ( + f"{remote_root}/scripts/run.py", + "print('hi')\n", + ) + ] + + +@pytest.mark.asyncio +async def test_ensure_remote_skill_assets_ready_uploads_binary_assets( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + (skill_root / "payload.bin").write_bytes(b"\xff\xfe\x00\x01") + + sandbox = _FakeSandbox() + remote_root = await ensure_remote_skill_assets_ready( + sandbox, + "browser-use", + { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["payload.bin"], + "digest": "deadbeefdeadbeef", + }, + }, + ) + + assert sandbox.file.written == [] + assert sandbox.file.written_base64 == [ + ( + f"{remote_root}/payload.bin", + "//4AAQ==", + ) + ] + assert sandbox.file.uploaded == [] + + +@pytest.mark.asyncio +async def test_ensure_remote_skill_assets_ready_fails_on_partial_remote_write( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + (skill_root / "config.json").write_text('{"debug": true}\n', encoding="utf-8") + (skill_root / "scripts").mkdir() + (skill_root / "scripts" / "run.py").write_text("print('hi')\n", encoding="utf-8") + + sandbox = _FailingWriteSandbox("scripts/run.py") + + with pytest.raises(RuntimeError, match="write remote execution asset 'scripts/run.py'"): + await ensure_remote_skill_assets_ready( + sandbox, + "browser-use", + { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["config.json", "scripts/run.py"], + "digest": "face1234face1234", + }, + }, + ) + + assert sandbox.file.written == [ + ( + "/remote/workspace/.aworld/skills/browser-use/face1234face1234/config.json", + '{"debug": true}\n', + ) + ] + assert sandbox._remote_skill_execution_roots == {} + + +@pytest.mark.asyncio +async def test_ensure_remote_skill_assets_ready_preserves_executable_modes( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + script = skill_root / "scripts" / "run.sh" + script.parent.mkdir() + script.write_text("#!/bin/sh\necho hi\n", encoding="utf-8") + script.chmod(0o755) + + sandbox = _FakeSandbox() + remote_root = await ensure_remote_skill_assets_ready( + sandbox, + "browser-use", + { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["scripts/run.sh"], + "digest": "bada55bada55bada", + }, + }, + ) + + assert sandbox.file.written == [ + ( + f"{remote_root}/scripts/run.sh", + "#!/bin/sh\necho hi\n", + ) + ] + assert sandbox.terminal.commands == [ + f"chmod 755 {remote_root}/scripts/run.sh" + ] + + +@pytest.mark.asyncio +async def test_ensure_remote_skill_assets_ready_skips_chmod_for_non_executable_files( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + config_file = skill_root / "config.json" + config_file.write_text('{"debug": true}\n', encoding="utf-8") + config_file.chmod(0o644) + + sandbox = _FakeSandbox() + await ensure_remote_skill_assets_ready( + sandbox, + "browser-use", + { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["config.json"], + "digest": "abba1234abba1234", + }, + }, + ) + + assert sandbox.terminal.commands == [] + + +@pytest.mark.asyncio +async def test_ensure_remote_skill_assets_ready_skips_chmod_on_windows_remote( + tmp_path: Path, +) -> None: + skill_root = tmp_path / "skills" / "browser-use" + skill_root.mkdir(parents=True) + script = skill_root / "scripts" / "run.sh" + script.parent.mkdir() + script.write_text("#!/bin/sh\necho hi\n", encoding="utf-8") + script.chmod(0o755) + + sandbox = _FakeSandbox(allowed_directories_data="Allowed directories:\nC:\\remote\\workspace") + await ensure_remote_skill_assets_ready( + sandbox, + "browser-use", + { + "asset_root": str(skill_root), + "execution_assets": { + "enabled": True, + "relative_paths": ["scripts/run.sh"], + "digest": "cafe1234cafe1234", + }, + }, + ) + + assert sandbox.terminal.commands == [] diff --git a/tests/sandbox/test_terminal_namespace.py b/tests/sandbox/test_terminal_namespace.py new file mode 100644 index 000000000..6085df07c --- /dev/null +++ b/tests/sandbox/test_terminal_namespace.py @@ -0,0 +1,234 @@ +import pytest +from mcp.types import TextContent + +from aworld.sandbox.namespaces.file import FileNamespace +from aworld.sandbox.namespaces.terminal import TerminalNamespace +from aworld.sandbox.run.mcp_servers import McpServers + + +def _terminal_tool(tool_name: str, param_name: str) -> dict[str, object]: + return { + "type": "function", + "function": { + "name": f"terminal__{tool_name}", + "parameters": { + "type": "object", + "properties": { + param_name: { + "type": "string", + } + }, + }, + }, + } + + +class _NamespaceSandbox: + def __init__(self) -> None: + self.mode = "remote" + self.sandbox_id = None + self.reuse = False + self.env_content_name = None + self._mcp_config = {"mcpServers": {"terminal": {}}} + self.mcp_config = self._mcp_config + self._skill_configs = { + "browser-use": { + "asset_root": "/host/skills/browser-use", + "execution_assets": { + "enabled": True, + "relative_paths": ["scripts/run.py"], + "digest": "feed1234feed1234", + }, + } + } + self.mcpservers = McpServers( + mcp_servers=["terminal"], + mcp_config=self._mcp_config, + sandbox=self, + skill_configs=self._skill_configs, + ) + self.mcpservers.tool_list = [_terminal_tool("run_code", "code")] + + async def ensure_skill_execution_assets_ready( + self, + skill_name: str, + skill_config: dict[str, object], + ) -> str: + raise RuntimeError(f"sync failed for {skill_name}") + + async def call_tool(self, action_list=None, task_id=None, session_id=None, context=None): + return await self.mcpservers.call_tool( + action_list=action_list, + task_id=task_id, + session_id=session_id, + context=context, + ) + + +@pytest.mark.asyncio +async def test_terminal_namespace_surfaces_remote_sync_failure() -> None: + sandbox = _NamespaceSandbox() + terminal = TerminalNamespace(sandbox) + + result = await terminal.run_code("python /skills/browser-use/scripts/run.py") + + assert result["success"] is False + assert "sync failed for browser-use" in result["data"] + assert result["error"] is None + + +class _SuccessfulNamespaceSandbox(_NamespaceSandbox): + def __init__(self) -> None: + super().__init__() + self.calls: list[tuple[str, dict[str, object]]] = [] + + async def ensure_skill_execution_assets_ready( + self, + skill_name: str, + skill_config: dict[str, object], + ) -> str: + self.calls.append((skill_name, skill_config)) + digest = skill_config["execution_assets"]["digest"] + return f"/remote/workspace/.aworld/skills/{skill_name}/{digest}" + + +@pytest.mark.asyncio +async def test_terminal_namespace_rewrites_host_skill_paths_for_remote_execution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + async def _fake_call(**kwargs): + captured.update(kwargs) + + class _Result: + content = [TextContent(type="text", text="done")] + + return _Result() + + monkeypatch.setattr( + "aworld.sandbox.run.mcp_servers.call_mcp_tool_with_exit_stack", + _fake_call, + ) + + sandbox = _SuccessfulNamespaceSandbox() + terminal = TerminalNamespace(sandbox) + + result = await terminal.run_code( + "cd /host/skills/browser-use && python /host/skills/browser-use/scripts/run.py" + ) + + assert result == {"success": True, "data": "done", "error": None} + assert captured["parameter"]["code"] == ( + "cd /remote/workspace/.aworld/skills/browser-use/feed1234feed1234" + " && python /remote/workspace/.aworld/skills/browser-use/feed1234feed1234/scripts/run.py" + ) + assert sandbox.calls == [("browser-use", sandbox._skill_configs["browser-use"])] + + +class _AliasedTerminalSandbox(_SuccessfulNamespaceSandbox): + def __init__(self) -> None: + super().__init__() + self._mcp_config = { + "mcpServers": { + "workspace_terminal": { + "headers": { + "MCP_SERVERS": "terminal", + } + } + } + } + self.mcp_config = self._mcp_config + self.mcpservers = McpServers( + mcp_servers=["workspace_terminal"], + mcp_config=self._mcp_config, + sandbox=self, + skill_configs=self._skill_configs, + ) + self.mcpservers.tool_list = [_terminal_tool("run_code", "code")] + self.mcpservers.tool_list[0]["function"]["name"] = "workspace_terminal__run_code" + + +class _ServerSuffixAliasSandbox(_SuccessfulNamespaceSandbox): + def __init__(self) -> None: + super().__init__() + self._mcp_config = { + "mcpServers": { + "terminal-server": {}, + "filesystem-server": {}, + } + } + self.mcp_config = self._mcp_config + self.mcpservers = McpServers( + mcp_servers=["terminal-server", "filesystem-server"], + mcp_config=self._mcp_config, + sandbox=self, + skill_configs=self._skill_configs, + ) + self.mcpservers.tool_list = [_terminal_tool("run_code", "code")] + self.mcpservers.tool_list[0]["function"]["name"] = "terminal-server__run_code" + + +@pytest.mark.asyncio +async def test_terminal_namespace_rewrites_remote_paths_for_aliased_terminal_service( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + async def _fake_call(**kwargs): + captured.update(kwargs) + + class _Result: + content = [TextContent(type="text", text="done")] + + return _Result() + + monkeypatch.setattr( + "aworld.sandbox.run.mcp_servers.call_mcp_tool_with_exit_stack", + _fake_call, + ) + + sandbox = _AliasedTerminalSandbox() + terminal = TerminalNamespace(sandbox) + + result = await terminal.run_code("python /host/skills/browser-use/scripts/run.py") + + assert result == {"success": True, "data": "done", "error": None} + assert captured["server_name"] == "workspace_terminal" + assert captured["parameter"]["code"] == ( + "python /remote/workspace/.aworld/skills/browser-use/feed1234feed1234/scripts/run.py" + ) + + +@pytest.mark.asyncio +async def test_terminal_namespace_resolves_server_suffix_aliases( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + async def _fake_call(**kwargs): + captured.update(kwargs) + + class _Result: + content = [TextContent(type="text", text="done")] + + return _Result() + + monkeypatch.setattr( + "aworld.sandbox.run.mcp_servers.call_mcp_tool_with_exit_stack", + _fake_call, + ) + + sandbox = _ServerSuffixAliasSandbox() + terminal = TerminalNamespace(sandbox) + filesystem = FileNamespace(sandbox) + + result = await terminal.run_code("python /host/skills/browser-use/scripts/run.py") + + assert result == {"success": True, "data": "done", "error": None} + assert terminal._service_name == "terminal-server" + assert filesystem._service_name == "filesystem-server" + assert captured["server_name"] == "terminal-server" + assert captured["parameter"]["code"] == ( + "python /remote/workspace/.aworld/skills/browser-use/feed1234feed1234/scripts/run.py" + ) diff --git a/tests/skills/test_execution_assets.py b/tests/skills/test_execution_assets.py new file mode 100644 index 000000000..eb4d5c49c --- /dev/null +++ b/tests/skills/test_execution_assets.py @@ -0,0 +1,236 @@ +from pathlib import Path + +from aworld.skills.execution_assets import ( + build_execution_asset_manifest, + build_execution_assets_config, + build_skill_path_aliases, + compute_execution_asset_digest, + discover_execution_asset_references, +) + + +def test_build_execution_asset_manifest_excludes_skill_markdown_by_default( + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "demo" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("---\n---\n", encoding="utf-8") + (skill_dir / "run.sh").write_text("echo hi\n", encoding="utf-8") + (skill_dir / "config.json").write_text('{"ok": true}\n', encoding="utf-8") + + manifest = build_execution_asset_manifest(skill_dir, declared_assets=None) + + assert "SKILL.md" not in manifest.relative_paths + assert "run.sh" in manifest.relative_paths + assert "config.json" in manifest.relative_paths + + +def test_compute_execution_asset_digest_is_stable_for_same_content( + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "demo" + skill_dir.mkdir() + (skill_dir / "run.sh").write_text("echo hi\n", encoding="utf-8") + + manifest = build_execution_asset_manifest(skill_dir, declared_assets=None) + + assert compute_execution_asset_digest(manifest) == compute_execution_asset_digest( + manifest + ) + + +def test_compute_execution_asset_digest_changes_when_permissions_change( + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "demo" + skill_dir.mkdir() + script = skill_dir / "run.sh" + script.write_text("echo hi\n", encoding="utf-8") + script.chmod(0o644) + + first = compute_execution_asset_digest( + build_execution_asset_manifest(skill_dir, declared_assets=None) + ) + + script.chmod(0o755) + second = compute_execution_asset_digest( + build_execution_asset_manifest(skill_dir, declared_assets=None) + ) + + assert first != second + + +def test_build_execution_assets_config_supports_declared_asset_list(tmp_path: Path) -> None: + skill_dir = tmp_path / "demo" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("---\n---\n", encoding="utf-8") + (skill_dir / "notes.md").write_text("# Notes\n", encoding="utf-8") + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "run").write_text("#!/bin/sh\necho hi\n", encoding="utf-8") + + config = build_execution_assets_config( + skill_dir, + declared_assets=["scripts/run", "notes.md"], + ) + + assert config["enabled"] is True + assert config["relative_paths"] == ["notes.md", "scripts/run"] + assert config["digest"] + + +def test_build_execution_assets_config_includes_scripts_directory_without_suffix( + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "demo" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("---\n---\n", encoding="utf-8") + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "run").write_text("#!/bin/sh\necho hi\n", encoding="utf-8") + + config = build_execution_assets_config(skill_dir) + + assert config["enabled"] is True + assert config["relative_paths"] == ["scripts/run"] + + +def test_build_execution_assets_config_includes_ts_js_helpers_outside_scripts( + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "demo" + skill_dir.mkdir() + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "index.ts").write_text( + "import '../src/foo.ts';\nimport '../lib/bar.js';\n", + encoding="utf-8", + ) + (skill_dir / "src").mkdir() + (skill_dir / "src" / "foo.ts").write_text("export const foo = 1;\n", encoding="utf-8") + (skill_dir / "lib").mkdir() + (skill_dir / "lib" / "bar.js").write_text("export const bar = 1;\n", encoding="utf-8") + + config = build_execution_assets_config( + skill_dir, + entrypoint="scripts/index.ts", + ) + + assert config["enabled"] is True + assert config["entrypoint"] == "scripts/index.ts" + assert config["relative_paths"] == ["lib/bar.js", "scripts/index.ts", "src/foo.ts"] + + +def test_build_execution_assets_config_includes_executable_helpers_outside_scripts( + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "demo" + skill_dir.mkdir() + (skill_dir / "bin").mkdir() + runner = skill_dir / "bin" / "runner" + runner.write_text("#!/bin/sh\necho hi\n", encoding="utf-8") + runner.chmod(0o755) + + config = build_execution_assets_config( + skill_dir, + usage_text="Run `cd /skills/demo && ./bin/runner` to execute this skill.", + skill_name="demo", + ) + + assert config["enabled"] is True + assert "bin/runner" in config["relative_paths"] + + +def test_build_execution_assets_config_infers_entrypoint_from_skill_usage_reference( + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "demo" + skill_dir.mkdir() + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "run.py").write_text("print('hi')\n", encoding="utf-8") + + config = build_execution_assets_config( + skill_dir, + usage_text="Run `python scripts/run.py` for this skill.", + skill_name="demo", + ) + + assert config["entrypoint"] == "scripts/run.py" + assert "scripts/run.py" in config["relative_paths"] + + +def test_build_execution_assets_config_resolves_virtual_skill_reference( + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "demo" + skill_dir.mkdir() + (skill_dir / "lint_check.py").write_text("print('lint')\n", encoding="utf-8") + + config = build_execution_assets_config( + skill_dir, + usage_text="Run `python /skills/demo/lint_check.py .` before review.", + skill_name="demo", + ) + + assert "lint_check.py" in config["relative_paths"] + + +def test_build_execution_assets_config_keeps_default_companion_assets_for_declared_entrypoint( + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "demo" + skill_dir.mkdir() + (skill_dir / "config.json").write_text('{"debug": true}\n', encoding="utf-8") + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "run.py").write_text("print('hi')\n", encoding="utf-8") + + config = build_execution_assets_config( + skill_dir, + entrypoint="scripts/run.py", + ) + + assert config["enabled"] is True + assert config["entrypoint"] == "scripts/run.py" + assert config["relative_paths"] == ["config.json", "scripts/run.py"] + + +def test_build_execution_assets_config_honors_explicit_disable_flag( + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "demo" + skill_dir.mkdir() + (skill_dir / "config.json").write_text('{"debug": true}\n', encoding="utf-8") + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "run.py").write_text("print('hi')\n", encoding="utf-8") + + config = build_execution_assets_config( + skill_dir, + declared_assets={"enabled": False}, + entrypoint="scripts/run.py", + ) + + assert config == { + "enabled": False, + "relative_paths": [], + "digest": "", + } + + +def test_build_skill_path_aliases_always_include_legacy_claude_paths() -> None: + aliases = build_skill_path_aliases( + skill_name="demo-skill", + usage_text="Run `python /skills/demo-skill/run.py`.", + ) + + assert aliases == [ + "/skills/demo-skill", + ".claude/skills/demo-skill", + "./.claude/skills/demo-skill", + "~/.claude/skills/demo-skill", + ] + + +def test_discover_execution_asset_references_supports_tilde_claude_skill_alias() -> None: + references = discover_execution_asset_references( + usage_text="SCRIPT=~/.claude/skills/demo-skill/scripts/run.py && python $SCRIPT", + skill_name="demo-skill", + ) + + assert references == ["scripts/run.py"] diff --git a/tests/skills/test_filesystem_skill_provider.py b/tests/skills/test_filesystem_skill_provider.py index ecd693c4e..8bce35b3b 100644 --- a/tests/skills/test_filesystem_skill_provider.py +++ b/tests/skills/test_filesystem_skill_provider.py @@ -28,12 +28,85 @@ def test_collect_skill_docs_uses_framework_adapter(tmp_path: Path): "---\ndescription: Browser automation\n---\n\n# Usage\nUse browser tools.\n", encoding="utf-8", ) + (skill_dir / "run.sh").write_text("echo browser\n", encoding="utf-8") docs = collect_skill_docs(tmp_path) assert docs["browser-use"]["description"] == "Browser automation" assert docs["browser-use"]["usage"] == "# Usage\nUse browser tools." assert docs["browser-use"]["asset_root"] == str(skill_dir.resolve()) + assert docs["browser-use"]["execution_assets"]["enabled"] is True + assert docs["browser-use"]["execution_assets"]["relative_paths"] == ["run.sh"] + + +def test_filesystem_provider_parses_declared_execution_assets(tmp_path: Path) -> None: + skill_dir = tmp_path / "skills" / "browser-use" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + ( + "---\n" + 'description: Browser automation\n' + 'execution_assets: ["notes.md", "scripts/run"]\n' + "---\n\n" + "# Usage\nUse browser tools.\n" + ), + encoding="utf-8", + ) + (skill_dir / "notes.md").write_text("# Notes\n", encoding="utf-8") + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "run").write_text("#!/bin/sh\necho browser\n", encoding="utf-8") + + provider = FilesystemSkillProvider(provider_id="local", root=tmp_path / "skills") + descriptor = provider.list_descriptors()[0] + + assert descriptor.execution_assets["enabled"] is True + assert descriptor.execution_assets["relative_paths"] == ["notes.md", "scripts/run"] + + +def test_collect_skill_docs_exposes_execution_entrypoint_from_usage_reference( + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "browser-use" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + ( + "---\n" + "description: Browser automation\n" + "---\n\n" + "Run `python scripts/run.py` to launch the workflow.\n" + ), + encoding="utf-8", + ) + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "run.py").write_text("print('browser')\n", encoding="utf-8") + + docs = collect_skill_docs(tmp_path) + + assert docs["browser-use"]["execution_assets"]["entrypoint"] == "scripts/run.py" + + +def test_collect_skill_docs_exposes_execution_entrypoint_from_nested_yaml_metadata( + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "browser-use" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + ( + "---\n" + "description: Browser automation\n" + "metadata:\n" + " entrypoint: scripts/index.ts\n" + "---\n\n" + "Run the browser workflow.\n" + ), + encoding="utf-8", + ) + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "index.ts").write_text("console.log('browser');\n", encoding="utf-8") + + docs = collect_skill_docs(tmp_path) + + assert docs["browser-use"]["execution_assets"]["entrypoint"] == "scripts/index.ts" def test_build_compat_registry_lists_descriptors(tmp_path: Path): diff --git a/tests/skills/test_plugin_skill_provider.py b/tests/skills/test_plugin_skill_provider.py index 3582c1010..ab1625a22 100644 --- a/tests/skills/test_plugin_skill_provider.py +++ b/tests/skills/test_plugin_skill_provider.py @@ -74,3 +74,128 @@ def test_plugin_provider_lists_descriptor_without_decoding_invalid_body( assert len(descriptors) == 1 assert descriptors[0].description == "Design before implementation" + + +def test_plugin_provider_reads_entrypoint_from_plugin_metadata(tmp_path: Path) -> None: + plugin_root = tmp_path / "plugin-skill" + (plugin_root / ".aworld-plugin").mkdir(parents=True) + skill_dir = plugin_root / "skills" / "swarm" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\ndescription: Run swarm\n---\n\n# Swarm\n", + encoding="utf-8", + ) + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "index.ts").write_text("export {};\n", encoding="utf-8") + (plugin_root / ".aworld-plugin" / "plugin.json").write_text( + json.dumps( + { + "id": "plugin-skill", + "version": "0.1.0", + "entrypoints": { + "skills": [ + { + "id": "swarm", + "target": "skills/swarm/SKILL.md", + "metadata": {"entrypoint": "scripts/index.ts"}, + } + ] + }, + } + ), + encoding="utf-8", + ) + + plugin = discover_plugins([plugin_root])[0] + provider = PluginSkillProvider(plugin) + descriptor = provider.list_descriptors()[0] + + assert descriptor.execution_assets["entrypoint"] == "scripts/index.ts" + + +def test_plugin_provider_load_content_uses_entrypoint_id_for_virtual_skill_refs( + tmp_path: Path, +) -> None: + plugin_root = tmp_path / "plugin-skill" + (plugin_root / ".aworld-plugin").mkdir(parents=True) + skill_dir = plugin_root / "skills" / "internal-skill-dir" + skill_dir.mkdir(parents=True) + (skill_dir / "assets").mkdir() + (skill_dir / "assets" / "template.bin").write_bytes(b"BIN") + (skill_dir / "SKILL.md").write_text( + "---\ndescription: Run external skill\n---\n\n" + "Read `/skills/public-skill/assets/template.bin` before execution.\n", + encoding="utf-8", + ) + (plugin_root / ".aworld-plugin" / "plugin.json").write_text( + json.dumps( + { + "id": "plugin-skill", + "version": "0.1.0", + "entrypoints": { + "skills": [ + { + "id": "public-skill", + "target": "skills/internal-skill-dir/SKILL.md", + } + ] + }, + } + ), + encoding="utf-8", + ) + + plugin = discover_plugins([plugin_root])[0] + provider = PluginSkillProvider(plugin) + descriptor = provider.list_descriptors()[0] + content = provider.load_content(descriptor.skill_id) + + assert content.execution_assets["enabled"] is True + assert content.execution_assets["relative_paths"] == ["assets/template.bin"] + + +def test_plugin_provider_load_content_preserves_metadata_execution_assets( + tmp_path: Path, +) -> None: + plugin_root = tmp_path / "plugin-skill" + (plugin_root / ".aworld-plugin").mkdir(parents=True) + skill_dir = plugin_root / "skills" / "brainstorming" + skill_dir.mkdir(parents=True) + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "run.py").write_text("print('hi')\n", encoding="utf-8") + (skill_dir / "notes.txt").write_text("notes\n", encoding="utf-8") + (skill_dir / "SKILL.md").write_text( + "---\ndescription: Design before implementation\n---\n\n# Brainstorming\n", + encoding="utf-8", + ) + (plugin_root / ".aworld-plugin" / "plugin.json").write_text( + json.dumps( + { + "id": "plugin-skill", + "version": "0.1.0", + "entrypoints": { + "skills": [ + { + "id": "brainstorming", + "target": "skills/brainstorming/SKILL.md", + "metadata": { + "execution_assets": { + "enabled": True, + "relative_paths": ["notes.txt"], + } + }, + } + ] + }, + } + ), + encoding="utf-8", + ) + + plugin = discover_plugins([plugin_root])[0] + provider = PluginSkillProvider(plugin) + descriptor = provider.list_descriptors()[0] + content = provider.load_content(descriptor.skill_id) + + assert content.execution_assets["enabled"] is True + assert content.execution_assets["relative_paths"] == ["notes.txt"] diff --git a/tests/skills/test_skill_registry.py b/tests/skills/test_skill_registry.py index 64069a946..54c998d56 100644 --- a/tests/skills/test_skill_registry.py +++ b/tests/skills/test_skill_registry.py @@ -27,6 +27,12 @@ def list_descriptors(self): asset_root="/tmp/browser-use", skill_file="/tmp/browser-use/SKILL.md", metadata={"type": "agent", "active": True}, + execution_assets={ + "enabled": True, + "relative_paths": ["run.sh"], + "digest": "deadbeefdeadbeef", + "entrypoint": "run.sh", + }, requirements={"eligible": True}, ) ] @@ -77,5 +83,17 @@ def test_registry_builds_compat_skill_config(): "active": True, "skill_path": "/tmp/browser-use/SKILL.md", "asset_root": "/tmp/browser-use", + "path_aliases": [ + "/skills/browser-use", + ".claude/skills/browser-use", + "./.claude/skills/browser-use", + "~/.claude/skills/browser-use", + ], + "execution_assets": { + "enabled": True, + "relative_paths": ["run.sh"], + "digest": skill_config["execution_assets"]["digest"], + "entrypoint": "run.sh", + }, "aworld_metadata": {"eligible": True}, }