Skip to content

fix(skills): support rootless skill zip uploads#6982

Open
ANGJustinl wants to merge 1 commit intoAstrBotDevs:masterfrom
ANGJustinl:master
Open

fix(skills): support rootless skill zip uploads#6982
ANGJustinl wants to merge 1 commit intoAstrBotDevs:masterfrom
ANGJustinl:master

Conversation

@ANGJustinl
Copy link
Copy Markdown

@ANGJustinl ANGJustinl commented Mar 26, 2026

This PR fixes skill zip uploads in the WebUI

Currently, skill uploads may fail when the zip does not contain a single top-level folder. Many skill packages downloaded from external sites place the skill files directly at the zip root, or place SKILL.md inside a nested directory instead of skillname/. This causes valid skill packages to be rejected during upload.

This change makes skill zip installation more tolerant while still keeping archive validation strict enough to reject ambiguous multi-skill packages.

Modifications / 改动点

  • Updated astrbot/core/skills/skill_manager.py

    • Detect the skill root by locating SKILL.md or skill.md in the archive instead of requiring exactly one top-level folder.
    • Support zip archives where the skill content is placed directly at the archive root.
    • Support archives where the actual skill root is nested inside another directory.
    • Keep rejecting ambiguous archives that contain multiple unrelated skill roots.
    • Infer the installed skill name from the detected root, SKILL.md frontmatter, or the zip filename when needed.
  • Added tests in tests/test_skill_zip_install.py

    • Cover root-level skill archives.
    • Cover nested skill root archives.
    • Cover rejection of archives with multiple skill roots.
  • Extended tests/test_dashboard.py

    • Added WebUI batch upload coverage for rootless skill archives.
  • Updated WebUI upload copy in:

    • dashboard/src/i18n/locales/en-US/features/extension.json
    • dashboard/src/i18n/locales/zh-CN/features/extension.json
    • dashboard/src/i18n/locales/ru-RU/features/extension.json
    • Clarified that the system now detects the skill root automatically.
  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

Verification steps executed locally:

uv run ruff format .
uv run ruff check .
uv run pytest tests/test_skill_zip_install.py tests/test_dashboard.py -k 'rootless or valid_skill_archive or multiple_skill_roots or nested_zip_root'

Screenshots or Test Results / 运行截图或测试结果

image image

Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。

  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”

  • 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txtpyproject.toml 文件相应位置。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

Summary by Sourcery

Relax skill zip installation to detect the skill root from SKILL.md and infer the skill name, allowing rootless and nested archives while still rejecting ambiguous multi-skill packages.

New Features:

  • Support installing skills from zip archives whose contents live at the archive root or in a nested directory instead of requiring a single top-level folder.

Bug Fixes:

  • Fix WebUI skill uploads failing for valid skill packages that do not use a single top-level folder or place SKILL.md at the archive root.

Enhancements:

  • Harden zip archive path normalization and validation when extracting uploaded skills.
  • Infer skill names from SKILL.md frontmatter, folder name, or zip filename to improve robustness of skill installation.

Documentation:

  • Update WebUI extension upload copy in all supported locales to clarify automatic detection of the skill root.

Tests:

  • Add unit tests for installing skills from rootless, nested, and ambiguous multi-root zip archives.
  • Extend dashboard batch upload tests to cover rootless skill archives.

@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a common issue where valid skill packages were rejected during WebUI upload due to strict zip archive structure requirements. The changes introduce a more tolerant skill installation process by intelligently detecting the skill's root directory within a zip file, whether it's at the root level or nested. This significantly improves the user experience by allowing a wider variety of skill package formats while maintaining validation against ambiguous multi-skill archives.

Highlights

  • Flexible Skill Zip Uploads: The skill upload mechanism in the WebUI has been enhanced to support skill zip archives that do not contain a single top-level folder, or where SKILL.md is nested. The system now automatically detects the skill root by locating SKILL.md or skill.md.
  • Improved Skill Name Inference: The system can now infer the installed skill name from the detected root, SKILL.md frontmatter, or the zip filename, making uploads more robust.
  • Strict Validation for Multi-Skill Packages: Ambiguous archives containing multiple unrelated skill roots are still rejected, ensuring that each zip file corresponds to a single skill.
  • Comprehensive Testing: New tests have been added to cover root-level skill archives, nested skill roots, and the rejection of archives with multiple skill roots, including WebUI batch upload scenarios.
  • Updated WebUI Copy: The WebUI upload messages have been updated in English, Russian, and Chinese to clarify the new, more flexible skill archive structure requirements.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@ANGJustinl ANGJustinl marked this pull request as ready for review March 26, 2026 04:38
Copilot AI review requested due to automatic review settings March 26, 2026 04:38
@dosubot dosubot bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Mar 26, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request significantly refactors the skill installation process from zip archives. The install_skill_from_zip function has been updated to be more flexible, allowing SKILL.md to be located either at the root of the zip archive or within a nested directory. New helper functions were introduced to normalize zip entry paths, discover the skill root within an archive, parse skill frontmatter, and infer the skill name from various sources. This ensures that only one skill can be installed per zip file. Corresponding UI text in the dashboard has been updated to reflect these new capabilities, and comprehensive unit tests have been added to cover rootless, nested, and multi-skill archive scenarios.

@dosubot dosubot bot added the area:webui The bug / feature is about webui(dashboard) of astrbot. label Mar 26, 2026
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="astrbot/core/skills/skill_manager.py" line_range="673-682" />
<code_context>
+            validated_members: list[tuple[zipfile.ZipInfo, PurePosixPath]] = []
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Extraction still uses the raw zip filenames rather than the normalized paths, which may diverge from the validated structure.

You compute a normalized path for each `ZipInfo` but then extract using `member.filename`, so the actual directory structure comes from the raw names, not the validated paths. To avoid discrepancies (especially with odd path forms), either extract using the normalized paths (e.g., manually writing files) or add a check that `Path(member.filename)` under `tmp_dir` resolves to the same location as `normalized_path`.

Suggested implementation:

```python
            validated_members: list[tuple[zipfile.ZipInfo, PurePosixPath]] = []
            file_paths: list[PurePosixPath] = []
            normalized_paths: list[PurePosixPath] = []

            for member in zf.infolist():

```

To fully implement the suggestion, you should adjust the code inside and after the `for member in zf.infolist():` loop as follows (exact code will depend on the current implementation):

1. **Inside the loop (where you already compute the normalized path)**:
   - Assuming you already compute something like:
     ```python
     normalized_path = PurePosixPath(<some_normalization_logic_using_member.filename>)
     ```
   - Append it to `normalized_paths`:
     ```python
     normalized_paths.append(normalized_path)
     ```
   - Before appending to `validated_members`, compute the resolved targets under `tmp_dir` (or whatever temporary root you use):
     ```python
     raw_target = (tmp_dir / member.filename).resolve()
     normalized_target = (tmp_dir / normalized_path).resolve()
     if raw_target != normalized_target:
         raise ValueError(
             f"Zip member {member.filename!r} resolves to {raw_target} "
             f"which does not match validated normalized path {normalized_target}"
         )
     ```
   - Only after this check, append to `validated_members`:
     ```python
     validated_members.append((member, normalized_path))
     ```

2. **Where you perform extraction**:
   - If you currently do:
     ```python
     for member in zf.infolist():
         zf.extract(member, path=tmp_dir)
     ```
     change it to iterate over `validated_members` and use the normalized path:
     ```python
     for member, normalized_path in validated_members:
         target_path = (tmp_dir / normalized_path).resolve()
         target_path.parent.mkdir(parents=True, exist_ok=True)
         with zf.open(member, "r") as src, target_path.open("wb") as dst:
             shutil.copyfileobj(src, dst)
     ```
   - This ensures the actual filesystem layout derives from the validated normalized paths, not the raw zip filenames.

3. **Imports**:
   - Ensure the file has:
     ```python
     from pathlib import Path, PurePosixPath
     import shutil
     ```
     (or re-use existing imports if they are already present).

These changes will guarantee that either:
- Extraction strictly follows the normalized/validated structure, or
- A mismatch between the raw filename and normalized path is detected and rejected before extraction.
</issue_to_address>

### Comment 2
<location path="tests/test_skill_zip_install.py" line_range="72-81" />
<code_context>
+def test_install_skill_from_nested_zip_root(monkeypatch, tmp_path: Path):
</code_context>
<issue_to_address>
**suggestion (testing):** Exercise additional `SkillManager.install_skill_from_zip` name sources including `skill_name_hint` and invalid frontmatter.

The nested-root test currently covers only the auto-discovery + valid frontmatter path. Please also add tests for the remaining `_infer_skill_name` branches: (1) nested archive where `skill_name_hint` is provided and takes precedence over folder/frontmatter; (2) invalid folder and frontmatter names but valid sanitized zip stem; and (3) all name sources invalid, asserting that `ValueError("Unable to determine a valid skill name"...)` is raised, to lock in precedence and error handling behavior.
</issue_to_address>

### Comment 3
<location path="tests/test_skill_zip_install.py" line_range="100-109" />
<code_context>
+    ) == "echo ok"
+
+
+def test_install_skill_from_zip_rejects_multiple_skill_roots(
+    monkeypatch,
+    tmp_path: Path,
+):
+    manager, _skills_root = _make_manager(monkeypatch, tmp_path)
+    archive_path = tmp_path / "ambiguous.zip"
+    _write_zip(
+        archive_path,
+        {
+            "skill_a/SKILL.md": "---\nname: skill-a\n---\n",
+            "skill_b/SKILL.md": "---\nname: skill-b\n---\n",
+        },
+    )
+
+    with pytest.raises(ValueError, match="Multiple skill roots found"):
+        manager.install_skill_from_zip(str(archive_path))
</code_context>
<issue_to_address>
**suggestion (testing):** Add tests for zip path normalization and security checks (absolute paths and `..` entries).

The new `_normalize_zip_entry_path` and `_discover_skill_root_from_archive` helpers add stricter checks for absolute paths and `..` segments but their failure modes aren’t directly tested. Please add cases that build zips with entries like `/evil/SKILL.md`, `C:\evil\SKILL.md` (Windows-style), and `../escape/SKILL.md`, and assert that `install_skill_from_zip` raises `ValueError` with the expected messages so regressions in the normalization logic are caught early.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +673 to +682
validated_members: list[tuple[zipfile.ZipInfo, PurePosixPath]] = []
file_paths: list[PurePosixPath] = []

archive_skill_name = None
if skill_name_hint is not None:
archive_skill_name = _normalize_skill_name(skill_name_hint)
if archive_skill_name and not _SKILL_NAME_RE.fullmatch(
archive_skill_name
):
raise ValueError("Invalid skill name.")
for member in zf.infolist():
normalized_path = _normalize_zip_entry_path(member.filename)
if normalized_path is None:
continue
validated_members.append((member, normalized_path))
if not member.is_dir():
file_paths.append(normalized_path)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Extraction still uses the raw zip filenames rather than the normalized paths, which may diverge from the validated structure.

You compute a normalized path for each ZipInfo but then extract using member.filename, so the actual directory structure comes from the raw names, not the validated paths. To avoid discrepancies (especially with odd path forms), either extract using the normalized paths (e.g., manually writing files) or add a check that Path(member.filename) under tmp_dir resolves to the same location as normalized_path.

Suggested implementation:

            validated_members: list[tuple[zipfile.ZipInfo, PurePosixPath]] = []
            file_paths: list[PurePosixPath] = []
            normalized_paths: list[PurePosixPath] = []

            for member in zf.infolist():

To fully implement the suggestion, you should adjust the code inside and after the for member in zf.infolist(): loop as follows (exact code will depend on the current implementation):

  1. Inside the loop (where you already compute the normalized path):

    • Assuming you already compute something like:
      normalized_path = PurePosixPath(<some_normalization_logic_using_member.filename>)
    • Append it to normalized_paths:
      normalized_paths.append(normalized_path)
    • Before appending to validated_members, compute the resolved targets under tmp_dir (or whatever temporary root you use):
      raw_target = (tmp_dir / member.filename).resolve()
      normalized_target = (tmp_dir / normalized_path).resolve()
      if raw_target != normalized_target:
          raise ValueError(
              f"Zip member {member.filename!r} resolves to {raw_target} "
              f"which does not match validated normalized path {normalized_target}"
          )
    • Only after this check, append to validated_members:
      validated_members.append((member, normalized_path))
  2. Where you perform extraction:

    • If you currently do:
      for member in zf.infolist():
          zf.extract(member, path=tmp_dir)
      change it to iterate over validated_members and use the normalized path:
      for member, normalized_path in validated_members:
          target_path = (tmp_dir / normalized_path).resolve()
          target_path.parent.mkdir(parents=True, exist_ok=True)
          with zf.open(member, "r") as src, target_path.open("wb") as dst:
              shutil.copyfileobj(src, dst)
    • This ensures the actual filesystem layout derives from the validated normalized paths, not the raw zip filenames.
  3. Imports:

    • Ensure the file has:
      from pathlib import Path, PurePosixPath
      import shutil
      (or re-use existing imports if they are already present).

These changes will guarantee that either:

  • Extraction strictly follows the normalized/validated structure, or
  • A mismatch between the raw filename and normalized path is detected and rejected before extraction.

Comment on lines +72 to +81
def test_install_skill_from_nested_zip_root(monkeypatch, tmp_path: Path):
manager, skills_root = _make_manager(monkeypatch, tmp_path)
archive_path = tmp_path / "nested.zip"
_write_zip(
archive_path,
{
"downloaded-package/README.md": "outer readme",
"downloaded-package/skills/demo_skill/SKILL.md": (
"---\n"
"name: demo-skill\n"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Exercise additional SkillManager.install_skill_from_zip name sources including skill_name_hint and invalid frontmatter.

The nested-root test currently covers only the auto-discovery + valid frontmatter path. Please also add tests for the remaining _infer_skill_name branches: (1) nested archive where skill_name_hint is provided and takes precedence over folder/frontmatter; (2) invalid folder and frontmatter names but valid sanitized zip stem; and (3) all name sources invalid, asserting that ValueError("Unable to determine a valid skill name"...) is raised, to lock in precedence and error handling behavior.

Comment on lines +100 to +109
def test_install_skill_from_zip_rejects_multiple_skill_roots(
monkeypatch,
tmp_path: Path,
):
manager, _skills_root = _make_manager(monkeypatch, tmp_path)
archive_path = tmp_path / "ambiguous.zip"
_write_zip(
archive_path,
{
"skill_a/SKILL.md": "---\nname: skill-a\n---\n",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add tests for zip path normalization and security checks (absolute paths and .. entries).

The new _normalize_zip_entry_path and _discover_skill_root_from_archive helpers add stricter checks for absolute paths and .. segments but their failure modes aren’t directly tested. Please add cases that build zips with entries like /evil/SKILL.md, C:\evil\SKILL.md (Windows-style), and ../escape/SKILL.md, and assert that install_skill_from_zip raises ValueError with the expected messages so regressions in the normalization logic are caught early.

@ANGJustinl
Copy link
Copy Markdown
Author

已进行部署测试image

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves AstrBot skill zip installation so the backend can install skills from archives that don’t have a single top-level folder (e.g., skill files at the zip root or nested under an extra wrapper directory), and updates the WebUI copy/tests accordingly.

Changes:

  • Update skill zip installation to detect the skill root by locating SKILL.md / skill.md, infer the installed skill name, and keep rejecting ambiguous multi-root archives.
  • Add unit tests for root-level, nested-root, and ambiguous multi-root skill archives; extend dashboard batch-upload tests for rootless archives.
  • Update WebUI i18n strings (EN/ZH/RU) to clarify automatic skill root detection.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
astrbot/core/skills/skill_manager.py Implements root discovery via SKILL.md, path normalization, and name inference for more tolerant zip installs.
tests/test_skill_zip_install.py Adds focused unit tests for rootless/nested skill zips and ambiguous multi-root rejection.
tests/test_dashboard.py Extends batch-upload API test coverage to include rootless skill archives.
dashboard/src/i18n/locales/en-US/features/extension.json Updates upload hint text to reflect auto-detected skill roots.
dashboard/src/i18n/locales/zh-CN/features/extension.json Updates upload hint text to reflect auto-detected skill roots.
dashboard/src/i18n/locales/ru-RU/features/extension.json Updates upload hint text to reflect auto-detected skill roots.

if not member_name or _is_ignored_zip_entry(member_name):
continue
extraction_root = Path(tmp_dir)
for member, _normalized_path in validated_members:
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

install_skill_from_zip() computes skill_root_rel before extraction, but still extracts all validated zip members into the temp directory, including files outside the detected skill root. This increases disk usage and exposure to zip-bomb style uploads, and it’s unnecessary since you already track each member’s normalized path. Consider filtering validated_members to only those under skill_root_rel (plus any required parent dirs) or rejecting archives that contain entries outside the chosen root to keep validation strict and avoid extracting unrelated content.

Suggested change
for member, _normalized_path in validated_members:
for member, normalized_path in validated_members:
# Only extract entries that belong to the detected skill root.
# If skill_root_rel has no parts, the whole archive is treated as the root.
if skill_root_rel.parts and not normalized_path.is_relative_to(
skill_root_rel
):
continue

Copilot uses AI. Check for mistakes.
Comment on lines +188 to +215
def _discover_skill_root_from_archive(
file_paths: list[PurePosixPath],
) -> PurePosixPath:
candidate_dirs = {
path.parent for path in file_paths if path.name in _SKILL_MARKDOWN_FILENAMES
}
if not candidate_dirs:
raise ValueError("SKILL.md not found in the zip archive.")

ordered_candidates = sorted(
candidate_dirs, key=lambda path: (len(path.parts), str(path))
)
shallowest_depth = len(ordered_candidates[0].parts)
shallowest_candidates = [
path for path in ordered_candidates if len(path.parts) == shallowest_depth
]
if len(shallowest_candidates) != 1:
raise ValueError(
"Multiple skill roots found in the zip archive. Please upload one skill per zip."
)

skill_root = shallowest_candidates[0]
for candidate in ordered_candidates[1:]:
if not _path_is_ancestor_or_same(skill_root, candidate):
raise ValueError(
"Multiple skill roots found in the zip archive. Please upload one skill per zip."
)
return skill_root
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_discover_skill_root_from_archive() returns path.parent for SKILL.md entries, which for a root-level SKILL.md is typically PurePosixPath('.'). The subsequent logic treats .parts as indicating “has a directory”, which can make root-level archives behave like nested ones (e.g., the if not skill_root_rel.parts / if skill_root_rel.parts branches become misleading or unreachable), and it can also make the ancestor check treat the root as not an ancestor of nested candidates. Consider normalizing a root-level skill root to an explicit sentinel (e.g., empty tuple / None) or special-casing PurePosixPath('.') so root-depth is 0 and ancestor checks work as intended.

Copilot uses AI. Check for mistakes.
@Soulter
Copy link
Copy Markdown
Member

Soulter commented Mar 27, 2026

what are the differences with #6952?

@ANGJustinl
Copy link
Copy Markdown
Author

what are the differences with #6952?

Support rootless skill upload, i didnt seen #6952 when commiting 😭

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:webui The bug / feature is about webui(dashboard) of astrbot. size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants