From 9373917b7f20f0b69c72dfe39ba3dbaa9be572b9 Mon Sep 17 00:00:00 2001 From: ccsang Date: Sun, 15 Mar 2026 09:19:06 +0000 Subject: [PATCH 1/5] fix(skills): use actual sandbox path from cache instead of hardcoded workspace root Fixes #6273 When using Shipyard booter, the sandbox workspace directory is `/home/ship_{session_id}/workspace/` instead of the hardcoded `/workspace`. This caused Agent to fail reading SKILL.md files with 'No such file or directory'. Changes: - In build_skills_prompt: prefer skill.path (from sandbox cache) over hardcoded SANDBOX_WORKSPACE_ROOT for sandbox_only skills - In list_skills: always prefer sandbox_cached_paths over hardcoded path for sandbox_only skills The actual path is resolved at sandbox scan time via Path.resolve() in _build_scan_command, which returns the correct absolute path based on the sandbox's actual working directory. --- astrbot/core/skills/skill_manager.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index 9bbdb5aee8..08c2794270 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -154,10 +154,13 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str: description = "Read SKILL.md for details." if skill.source_type == "sandbox_only": - rendered_path = ( - f"{str(SANDBOX_WORKSPACE_ROOT)}/{str(SANDBOX_SKILLS_ROOT)}/" - f"{display_name}/SKILL.md" - ) + # Prefer the actual path from sandbox cache if available + rendered_path = _sanitize_prompt_path_for_prompt(skill.path) + if not rendered_path: + rendered_path = ( + f"{str(SANDBOX_WORKSPACE_ROOT)}/{str(SANDBOX_SKILLS_ROOT)}/" + f"{display_name}/SKILL.md" + ) else: rendered_path = _sanitize_prompt_path_for_prompt(skill.path) if not rendered_path: @@ -389,12 +392,10 @@ def list_skills( if active_only and not active: continue description = sandbox_cached_descriptions.get(skill_name, "") - if show_sandbox_path: + # Prefer the actual path from sandbox cache if available + path_str = sandbox_cached_paths.get(skill_name, "") + if not path_str: path_str = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" - else: - path_str = sandbox_cached_paths.get(skill_name, "") - if not path_str: - path_str = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" skills_by_name[skill_name] = SkillInfo( name=skill_name, description=description, From eeaba796f783c989a666a77302d0b4de7bc78e57 Mon Sep 17 00:00:00 2001 From: ccsang Date: Sun, 15 Mar 2026 09:33:47 +0000 Subject: [PATCH 2/5] docs: add comment explaining show_sandbox_path behavior for sandbox_only skills Address Sourcery AI review comment: - Clarify that show_sandbox_path is implicitly True for sandbox_only skills - Explain why the flag is effectively ignored (no local path exists) --- astrbot/core/skills/skill_manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index 08c2794270..26a704ac09 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -392,7 +392,9 @@ def list_skills( if active_only and not active: continue description = sandbox_cached_descriptions.get(skill_name, "") - # Prefer the actual path from sandbox cache if available + # For sandbox_only skills, show_sandbox_path is implicitly True + # since there is no local path to show. Always prefer the + # actual path from sandbox cache. path_str = sandbox_cached_paths.get(skill_name, "") if not path_str: path_str = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" From 09c5639db4739cc69d8dc2a3d0bd4163982f6d91 Mon Sep 17 00:00:00 2001 From: ccsang Date: Sun, 15 Mar 2026 16:00:44 +0000 Subject: [PATCH 3/5] refactor: simplify path_str fallback using or operator Address review feedback: use single-line fallback instead of if-not pattern. --- astrbot/core/skills/skill_manager.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index 26a704ac09..dd70cbc86d 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -395,9 +395,7 @@ def list_skills( # For sandbox_only skills, show_sandbox_path is implicitly True # since there is no local path to show. Always prefer the # actual path from sandbox cache. - path_str = sandbox_cached_paths.get(skill_name, "") - if not path_str: - path_str = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" + path_str = sandbox_cached_paths.get(skill_name) or f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" skills_by_name[skill_name] = SkillInfo( name=skill_name, description=description, From 4b565f2f0af06fb28de1edc72ae47898466bee4c Mon Sep 17 00:00:00 2001 From: ccsang Date: Mon, 16 Mar 2026 00:38:23 +0000 Subject: [PATCH 4/5] style: format skill_manager.py with ruff Fix ruff format-check failure --- astrbot/core/skills/skill_manager.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index dd70cbc86d..b1b168b23c 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -395,7 +395,10 @@ def list_skills( # For sandbox_only skills, show_sandbox_path is implicitly True # since there is no local path to show. Always prefer the # actual path from sandbox cache. - path_str = sandbox_cached_paths.get(skill_name) or f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" + path_str = ( + sandbox_cached_paths.get(skill_name) + or f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" + ) skills_by_name[skill_name] = SkillInfo( name=skill_name, description=description, From 2ee7a1081ee782fcfb9be889c730c52d7be772a6 Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Thu, 19 Mar 2026 19:58:26 +0800 Subject: [PATCH 5/5] fix(skills): sanitize cached sandbox skill paths Normalize sandbox cache paths before reading or writing them so invalid, empty, or mismatched entries fall back to a safe default SKILL.md path. This avoids using malformed cached paths, keeps path rendering consistent, and ensures sandbox skill listings always point to the expected workspace location. --- astrbot/core/skills/skill_manager.py | 55 +++++++++++++++++++--------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py index b1b168b23c..b724243881 100644 --- a/astrbot/core/skills/skill_manager.py +++ b/astrbot/core/skills/skill_manager.py @@ -27,6 +27,28 @@ _SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") +def _default_sandbox_skill_path(name: str) -> str: + return f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{name}/SKILL.md" + + +def _normalize_cached_sandbox_skill_path(name: str, path: str) -> str: + normalized = str(path or "").strip().replace("\\", "/") + if not normalized: + return _default_sandbox_skill_path(name) + + pure_path = PurePosixPath(normalized) + if ".." in pure_path.parts: + return _default_sandbox_skill_path(name) + + if pure_path.name != "SKILL.md": + return _default_sandbox_skill_path(name) + + if pure_path.parent.name != name: + return _default_sandbox_skill_path(name) + + return str(pure_path) + + def _is_ignored_zip_entry(name: str) -> bool: parts = PurePosixPath(name).parts if not parts: @@ -157,10 +179,7 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str: # Prefer the actual path from sandbox cache if available rendered_path = _sanitize_prompt_path_for_prompt(skill.path) if not rendered_path: - rendered_path = ( - f"{str(SANDBOX_WORKSPACE_ROOT)}/{str(SANDBOX_SKILLS_ROOT)}/" - f"{display_name}/SKILL.md" - ) + rendered_path = _default_sandbox_skill_path(skill.name) else: rendered_path = _sanitize_prompt_path_for_prompt(skill.path) if not rendered_path: @@ -274,13 +293,13 @@ def set_sandbox_skills_cache(self, skills: list[dict]) -> None: if not name or not _SKILL_NAME_RE.match(name): continue description = str(item.get("description", "") or "") - path = str(item.get("path", "") or "") - if not path: - path = f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{name}/SKILL.md" + path = _normalize_cached_sandbox_skill_path( + name, str(item.get("path", "") or "") + ) deduped[name] = { "name": name, "description": description, - "path": path.replace("\\", "/"), + "path": path, } cache = { "version": _SANDBOX_SKILLS_CACHE_VERSION, @@ -324,12 +343,13 @@ def list_skills( if not isinstance(item, dict): continue name = str(item.get("name", "") or "").strip() - path = str(item.get("path", "") or "").strip().replace("\\", "/") + path = _normalize_cached_sandbox_skill_path( + name, str(item.get("path", "") or "") + ) if not name or not _SKILL_NAME_RE.match(name): continue sandbox_cached_descriptions[name] = str(item.get("description", "") or "") - if path: - sandbox_cached_paths[name] = path + sandbox_cached_paths[name] = path for entry in sorted(Path(self.skills_root).iterdir()): if not entry.is_dir(): @@ -356,9 +376,9 @@ def list_skills( source_type = "both" if sandbox_exists else "local_only" source_label = "synced" if sandbox_exists else "local" if runtime == "sandbox" and show_sandbox_path: - path_str = sandbox_cached_paths.get(skill_name) or ( - f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" - ) + path_str = sandbox_cached_paths.get( + skill_name + ) or _default_sandbox_skill_path(skill_name) else: path_str = str(skill_md) path_str = path_str.replace("\\", "/") @@ -395,10 +415,9 @@ def list_skills( # For sandbox_only skills, show_sandbox_path is implicitly True # since there is no local path to show. Always prefer the # actual path from sandbox cache. - path_str = ( - sandbox_cached_paths.get(skill_name) - or f"{SANDBOX_WORKSPACE_ROOT}/{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md" - ) + path_str = sandbox_cached_paths.get( + skill_name + ) or _default_sandbox_skill_path(skill_name) skills_by_name[skill_name] = SkillInfo( name=skill_name, description=description,