# Conflicts: # apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts # apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts # apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts # apps/desktop/e2e/bot-mode-roster-localized.spec.ts # apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts # apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts # apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts # apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts # apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts # apps/desktop/e2e/bot-roster-user-sections.spec.ts # apps/desktop/e2e/bot-routines-pane-narrow.spec.ts # apps/desktop/e2e/bot-row-open-recent-session.spec.ts # apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts # apps/desktop/e2e/group-composer-auto-grow.spec.ts # apps/desktop/e2e/group-create-gate-remote-roster.spec.ts # apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts # apps/desktop/e2e/hosted-room-backend-continuity.spec.ts # apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts # apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts # apps/desktop/e2e/worktree-branch-status.spec.ts # apps/desktop/electron/backend-probes.test.ts # apps/desktop/electron/connection-apply.test.ts # apps/desktop/electron/desktop-electron-pin.test.ts # apps/desktop/electron/desktop-uninstall.test.ts # apps/desktop/electron/gateway-file-download-transport.test.ts # apps/desktop/electron/gateway-stop-before-update.test.ts # apps/desktop/electron/github-api-auth.test.ts # apps/desktop/electron/registry-primary-profile-scope.test.ts # apps/desktop/electron/update-api-check.test.ts # apps/desktop/electron/update-handoff-marker.test.ts # apps/desktop/electron/venv-blocker-scan.test.ts # apps/desktop/scripts/after-extract.test.mjs # apps/desktop/scripts/local-pack-publish.test.mjs # apps/desktop/scripts/tasks-scroll.test.mjs # apps/desktop/src/app/settings/model-settings.test.tsx # apps/desktop/src/app/updates-overlay.blockers.test.tsx # apps/desktop/src/components/desktop-install-overlay.test.tsx # apps/desktop/src/lib/update-copy.test.ts # scripts/ci/check_os_marker_fakes.py # tests-js/desktop-mac-usage-descriptions.test.ts # tests-js/node-engine-alignment.test.ts # tests/agent/lsp/test_install_and_lint_fixes.py # tests/agent/test_command_token_source.py # tests/agent/test_compression_boundary_hook.py # tests/agent/test_create_openai_client_ssl_verify.py # tests/agent/test_custom_provider_ca_probes.py # tests/agent/test_endpoint_blackhole.py # tests/agent/test_estimator_parity.py # tests/agent/test_in_place_compaction.py # tests/agent/test_moa_loop_mode.py # tests/agent/test_model_metadata.py # tests/agent/test_skill_session_platform_gate.py # tests/agent/test_skill_utils.py # tests/agent/test_ssl_ca_guard.py # tests/computer_use/test_doctor.py # tests/cron/test_codex_execution_paths.py # tests/cron/test_cron_bot_chat_delivery.py # tests/cron/test_cron_script.py # tests/cron/test_media_delivery_parity.py # tests/cron/test_misfire_catchup.py # tests/cron/test_parallel_pool.py # tests/cron/test_recurring_eagain_redispatch.py # tests/gateway/test_choice_picker.py # tests/gateway/test_control_socket_windows_live.py # tests/gateway/test_dingtalk.py # tests/gateway/test_feishu.py # tests/gateway/test_feishu_onboard.py # tests/gateway/test_gateway_shutdown.py # tests/gateway/test_matrix.py # tests/gateway/test_model_command_custom_providers.py # tests/gateway/test_reasoning_command.py # tests/gateway/test_runtime_footer.py # tests/gateway/test_session.py # tests/gateway/test_session_hygiene.py # tests/gateway/test_status.py # tests/gateway/test_teams.py # tests/gateway/test_turn_lease.py # tests/gateway/test_whatsapp_connect.py # tests/hermes_cli/test_approvals_command.py # tests/hermes_cli/test_auth_store_lock_concurrent.py # tests/hermes_cli/test_backup.py # tests/hermes_cli/test_banner_git_state.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_compat_manifest_targets.py # tests/hermes_cli/test_computer_use_cli.py # tests/hermes_cli/test_cpr_local_leak.py # tests/hermes_cli/test_dashboard_auth_gate.py # tests/hermes_cli/test_dashboard_procs_kill_grace.py # tests/hermes_cli/test_desktop_lifecycle_windows_live.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_command_install.py # tests/hermes_cli/test_fleet_config_migration_windows_live.py # tests/hermes_cli/test_gateway.py # tests/hermes_cli/test_gateway_platform_gating.py # tests/hermes_cli/test_gateway_restart_loop.py # tests/hermes_cli/test_gateway_task_probe.py # tests/hermes_cli/test_gateway_wsl.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_install_cua_driver.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_command_exports.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_linux_desktop_entry.py # tests/hermes_cli/test_local_runtime.py # tests/hermes_cli/test_local_runtime_updates.py # tests/hermes_cli/test_managed_uv.py # tests/hermes_cli/test_mcp_reload_confirm_gate.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_npm_engine.py # tests/hermes_cli/test_personality_none.py # tests/hermes_cli/test_pet_toggle.py # tests/hermes_cli/test_plan_reconciliation_windows_live.py # tests/hermes_cli/test_plugin_event_bus.py # tests/hermes_cli/test_plugin_manifest_v2.py # tests/hermes_cli/test_plugin_packs.py # tests/hermes_cli/test_plugins_cmd.py # tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py # tests/hermes_cli/test_process_identity.py # tests/hermes_cli/test_profiles.py # tests/hermes_cli/test_profiles_sidebar_cache.py # tests/hermes_cli/test_pty_bridge.py # tests/hermes_cli/test_resolve_turn_limit.py # tests/hermes_cli/test_serve_runtime_inventory.py # tests/hermes_cli/test_session_vacuum_config.py # tests/hermes_cli/test_set_config_value.py # tests/hermes_cli/test_signal_handler_kanban_worker.py # tests/hermes_cli/test_slash_confirm_windows.py # tests/hermes_cli/test_stale_pid_guard.py # tests/hermes_cli/test_startup_fast_guards.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_telegram_managed_bot.py # tests/hermes_cli/test_tools_config.py # tests/hermes_cli/test_update_apply_shallow_count.py # tests/hermes_cli/test_update_autostash.py # tests/hermes_cli/test_update_concurrent_quarantine.py # tests/hermes_cli/test_update_fetch_failure_classifier.py # tests/hermes_cli/test_update_fleet_probe_resume_token.py # tests/hermes_cli/test_update_handoff_backend_reap.py # tests/hermes_cli/test_update_handoff_desktop_rebuild.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_host_obligation.py # tests/hermes_cli/test_update_import_guard.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_update_inventory.py # tests/hermes_cli/test_update_launchd_unloaded_gateway.py # tests/hermes_cli/test_update_missing_configured_deps.py # tests/hermes_cli/test_update_modified_notice.py # tests/hermes_cli/test_update_multiplex_migration_hook.py # tests/hermes_cli/test_update_no_gateway_restart.py # tests/hermes_cli/test_update_orphan_backend_reap.py # tests/hermes_cli/test_update_parked_branch_guard.py # tests/hermes_cli/test_update_post_pull_syntax_guard.py # tests/hermes_cli/test_update_receipt.py # tests/hermes_cli/test_update_self_lock.py # tests/hermes_cli/test_update_shim_fail_closed.py # tests/hermes_cli/test_update_shim_self_lock.py # tests/hermes_cli/test_update_sqlite_remediation.py # tests/hermes_cli/test_update_stale_dashboard.py # tests/hermes_cli/test_update_stale_virtualenv.py # tests/hermes_cli/test_update_venv_health.py # tests/hermes_cli/test_update_venv_ownership_preflight.py # tests/hermes_cli/test_update_wedged_gateway.py # tests/hermes_cli/test_update_yes_flag.py # tests/hermes_cli/test_update_zip_two_phase.py # tests/hermes_cli/test_urllib_security.py # tests/hermes_cli/test_ux_messages_auth_config.py # tests/hermes_cli/test_ux_messages_startup.py # tests/hermes_cli/test_venv_holder_classifier.py # tests/hermes_cli/test_verify_console_scripts.py # tests/hermes_cli/test_verify_core_dependencies.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_server_console_ws.py # tests/hermes_cli/test_web_server_ws_ping.py # tests/hermes_cli/test_web_ui_build.py # tests/hermes_state/test_fts_rebuild_admission.py # tests/hermes_state/test_hermes_state.py # tests/plugins/memory/test_memory_lazy_install.py # tests/plugins/test_google_meet_plugin.py # tests/plugins/test_langfuse_plugin.py # tests/plugins/test_security_guidance_plugin.py # tests/plugins/test_transform_llm_output_hook.py # tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py # tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py # tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py # tests/scripts/install/test_install_clone_throttle_fallback.py # tests/scripts/install/test_install_lockfile_churn.py # tests/scripts/install/test_install_no_initial_commit.py # tests/scripts/install/test_install_sh_browser_install.py # tests/scripts/install/test_install_sh_node_prerelease.py # tests/scripts/install/test_install_sh_symlink_stomp.py # tests/scripts/install/test_install_sh_uv_lock_config.py # tests/scripts/install/test_install_unmerged_index.py # tests/scripts/test_contributor_map.py # tests/scripts/test_run_tests_parallel.py # tests/skills/test_competitor_news_monitor_skill.py # tests/skills/test_document_to_action_items_skill.py # tests/skills/test_google_workspace_setup.py # tests/skills/test_google_workspace_setup_deps.py # tests/skills/test_grounded_citations_skill.py # tests/skills/test_ip_as_logo_skill.py # tests/skills/test_live_dashboard_skill.py # tests/skills/test_mcp_oauth_remote_gateway_skill.py # tests/skills/test_office_document_skills.py # tests/skills/test_openclaw_migration.py # tests/skills/test_product_price_monitor_skill.py # tests/skills/test_scrollcraft_skill.py # tests/skills/test_setup_wizard_generator_skill.py # tests/skills/test_weekly_review_planning_skill.py # tests/test_engines_satisfiable.py # tests/test_fast_safe_load.py # tests/test_hermes_bootstrap.py # tests/test_hermes_constants.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/test_model_tools_async_bridge.py # tests/test_packaging_build_guard.py # tests/test_packaging_metadata.py # tests/test_yaml_indent_consistency.py # tests/tools/test_approval_timeout_overflow.py # tests/tools/test_base_environment.py # tests/tools/test_bot_mode_dm.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_hardening.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_use_cli.py # tests/tools/test_clipboard.py # tests/tools/test_code_execution.py # tests/tools/test_code_execution_modes.py # tests/tools/test_code_execution_windows_env.py # tests/tools/test_computer_use.py # tests/tools/test_delegate_liveness_timeout.py # tests/tools/test_execute_code_approval_cluster.py # tests/tools/test_execution_flag_detection.py # tests/tools/test_fal_common.py # tests/tools/test_file_operations.py # tests/tools/test_file_tools.py # tests/tools/test_file_tools_cwd_resolution.py # tests/tools/test_file_tools_live.py # tests/tools/test_lazy_deps.py # tests/tools/test_lazy_deps_durable_target.py # tests/tools/test_lazy_deps_managed.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_local_tempdir.py # tests/tools/test_macos_protected_search.py # tests/tools/test_mcp_npx_cached_bin.py # tests/tools/test_oneshot_completion_linger.py # tests/tools/test_process_registry.py # tests/tools/test_read_file_schema_gating.py # tests/tools/test_skill_improvements.py # tests/tools/test_skills_sync.py # tests/tools/test_termux_api_detection.py # tests/tools/test_tirith_security.py # tests/tools/test_transcription_tools.py # tests/tools/test_tts_streaming.py # tests/tools/test_wake_word.py # tests/tui_gateway/test_compute_host_borrowed_lease.py # tests/tui_gateway/test_compute_host_turn_protocol.py # tests/tui_gateway/test_isolated_orphan_activity.py # tests/tui_gateway/test_protocol.py # tests/tui_gateway/test_slash_worker_profile_home.py # tests/tui_gateway/test_subprocess_encoding.py # tests/tui_gateway/test_tui_gateway_server.py # ui-tui/src/__tests__/terminalParity.test.ts # ui-tui/src/__tests__/termuxComposerLayout.test.ts # ui-tui/src/__tests__/textInputFastEcho.test.ts
587 lines
24 KiB
Python
587 lines
24 KiB
Python
"""Multi-file third-party skill bundles and scanner provenance (#60598)."""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
from functools import partial
|
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
from io import StringIO
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from rich.console import Console
|
|
|
|
from tools.skills_hub_github import GitHubAuth, GitHubSource
|
|
from tools.skills_hub_models import SkillBundle
|
|
from tools.skills_hub_sources import UrlSource
|
|
|
|
|
|
SKILL_MD = """---
|
|
name: demo-bundle
|
|
description: A multi-file test skill.
|
|
---
|
|
# Demo
|
|
Read [the guide](references/guide.md#usage), use `templates/report.md?raw=1`, and run
|
|
`scripts/run.py`, `references/foo%23bar.md`, and `references/my%20guide.md`. See
|
|
`examples/endpoint-inventory.md`. The repository also
|
|
contains assets/logo.png.
|
|
"""
|
|
|
|
|
|
class _QuietHandler(SimpleHTTPRequestHandler):
|
|
def log_message(self, *_args):
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def served_repo(tmp_path, monkeypatch):
|
|
# The fixture intentionally serves over loopback. Keep exercising the real
|
|
# HTTP transport while opting this test server into private-address access.
|
|
monkeypatch.setattr("tools.url_safety._global_allow_private_urls", lambda: True)
|
|
|
|
repo = tmp_path / "upstream"
|
|
repo.mkdir()
|
|
(repo / "SKILL.md").write_text(SKILL_MD)
|
|
for rel, content in {
|
|
"references/guide.md": "safe guide\n",
|
|
"references/foo#bar.md": "encoded delimiter\n",
|
|
"references/my guide.md": "encoded space\n",
|
|
"templates/report.md": "report\n",
|
|
"scripts/run.py": "print('ok')\n",
|
|
"assets/logo.png": b"\x89PNG\r\n\x1a\n\x00\xff",
|
|
"examples/endpoint-inventory.md": "example\n",
|
|
"examples/not-installed.md": "must not be copied\n",
|
|
"README.md": "must not be copied\n",
|
|
}.items():
|
|
path = repo / rel
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
if isinstance(content, bytes):
|
|
path.write_bytes(content)
|
|
else:
|
|
path.write_text(content, newline="\n")
|
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
|
subprocess.run(["git", "add", "."], cwd=repo, check=True)
|
|
subprocess.run(
|
|
["git", "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "fixture"],
|
|
cwd=repo,
|
|
check=True,
|
|
)
|
|
|
|
server = ThreadingHTTPServer(
|
|
("127.0.0.1", 0), partial(_QuietHandler, directory=str(repo))
|
|
)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
yield repo, f"http://127.0.0.1:{server.server_port}/SKILL.md"
|
|
finally:
|
|
server.shutdown()
|
|
thread.join()
|
|
|
|
|
|
def test_url_source_fetches_only_referenced_allowed_support_directories(served_repo, monkeypatch):
|
|
_repo, url = served_repo
|
|
monkeypatch.setattr("tools.skills_hub.is_safe_url", lambda _url: True)
|
|
monkeypatch.setattr("tools.skills_hub.check_website_access", lambda _url: None)
|
|
|
|
bundle = UrlSource().fetch(url)
|
|
|
|
assert bundle is not None
|
|
assert set(bundle.files) == {
|
|
"SKILL.md",
|
|
"references/guide.md",
|
|
"references/foo#bar.md",
|
|
"references/my guide.md",
|
|
"templates/report.md",
|
|
"scripts/run.py",
|
|
"assets/logo.png",
|
|
"examples/endpoint-inventory.md",
|
|
}
|
|
assert bundle.files["assets/logo.png"] == b"\x89PNG\r\n\x1a\n\x00\xff"
|
|
assert bundle.files["references/foo#bar.md"] == b"encoded delimiter\n"
|
|
assert bundle.files["references/my guide.md"] == b"encoded space\n"
|
|
assert "examples/not-installed.md" not in bundle.files
|
|
assert bundle.metadata["source_url"] == url
|
|
|
|
|
|
def test_url_source_rejects_traversal_reference(monkeypatch):
|
|
source = UrlSource()
|
|
skill = "---\nname: bad\ndescription: bad\n---\n[bad](references/../../secret.txt)\n"
|
|
monkeypatch.setattr(source, "_fetch_text", lambda _url: skill)
|
|
|
|
assert source.fetch("https://example.com/bad/SKILL.md") is None
|
|
|
|
|
|
def test_same_dir_linked_siblings_are_fetched(served_repo, monkeypatch):
|
|
"""#96310: explicitly linked same-skill-directory files must ship in the
|
|
bundle — dropping them made installs "succeed" with unresolved links."""
|
|
repo, url = served_repo
|
|
(repo / "CONTEXT-FORMAT.md").write_text("format\n", newline="\n")
|
|
(repo / "DEEPENING.md").write_text("deepening\n", newline="\n")
|
|
(repo / "SKILL.md").write_text(SKILL_MD + "See [the format](./CONTEXT-FORMAT.md) and [deepening](DEEPENING.md).\n")
|
|
monkeypatch.setattr("tools.skills_hub.is_safe_url", lambda _url: True)
|
|
monkeypatch.setattr("tools.skills_hub.check_website_access", lambda _url: None)
|
|
|
|
bundle = UrlSource().fetch(url)
|
|
|
|
assert bundle is not None
|
|
assert bundle.files["CONTEXT-FORMAT.md"] == b"format\n"
|
|
assert bundle.files["DEEPENING.md"] == b"deepening\n"
|
|
# Unlinked siblings stay excluded — same fetch-minimization contract.
|
|
assert "README.md" not in bundle.files
|
|
|
|
|
|
def test_same_dir_link_outside_skill_dir_is_skipped_not_fatal(served_repo, monkeypatch):
|
|
"""A repo-relative link above the skill directory is prose, not a bundle path (#115171).
|
|
|
|
Nothing is fetched for it, so it must not reject the bundle: the skill installs with
|
|
SKILL.md and its real siblings, and the outside link is simply left dangling.
|
|
"""
|
|
repo, url = served_repo
|
|
(repo / "DEFS.md").write_text("defs\n")
|
|
(repo / "SKILL.md").write_text(SKILL_MD + "[registry](../../tools/REGISTRY.md) and [defs](./DEFS.md)\n")
|
|
monkeypatch.setattr("tools.skills_hub.is_safe_url", lambda _url: True)
|
|
monkeypatch.setattr("tools.skills_hub.check_website_access", lambda _url: None)
|
|
|
|
bundle = UrlSource().fetch(url)
|
|
|
|
assert bundle is not None
|
|
assert "DEFS.md" in bundle.files
|
|
assert not any(".." in name for name in bundle.files)
|
|
|
|
|
|
def test_same_dir_link_without_extension_is_ignored(monkeypatch):
|
|
"""Prose targets that aren't file links (no extension) never fetch."""
|
|
from tools.skills_hub_models import _referenced_support_paths
|
|
|
|
skill = "---\nname: x\ndescription: x\n---\nsee [notes](NOTES) and `README`\n"
|
|
assert _referenced_support_paths(skill) == set()
|
|
|
|
|
|
def test_same_dir_link_query_and_fragment_are_stripped():
|
|
"""?query and #fragment never leak into the fetched bundle path."""
|
|
from tools.skills_hub_models import _referenced_support_paths
|
|
|
|
skill = (
|
|
"---\nname: x\ndescription: x\n---\n"
|
|
"[a](CONTEXT-FORMAT.md?raw=1) [b](DEEPENING.md#usage)\n"
|
|
)
|
|
assert _referenced_support_paths(skill) == {"CONTEXT-FORMAT.md", "DEEPENING.md"}
|
|
|
|
|
|
def test_case_variant_of_skill_md_is_never_a_sibling_entry():
|
|
"""skill.md must not ship as a bundle file (case-insensitive FS collision)."""
|
|
from tools.skills_hub_models import _referenced_support_paths
|
|
|
|
skill = "---\nname: x\ndescription: x\n---\n[home](skill.md)\n"
|
|
assert _referenced_support_paths(skill) == set()
|
|
|
|
|
|
def test_case_folded_sibling_collision_drops_the_pair():
|
|
"""A.md + a.md would collide on install — neither ships."""
|
|
from tools.skills_hub_models import _referenced_support_paths
|
|
|
|
skill = "---\nname: x\ndescription: x\n---\n[a](A.md) [a2](a.md)\n"
|
|
assert _referenced_support_paths(skill) == set()
|
|
|
|
|
|
def test_github_fetches_pin_to_the_tree_revision(monkeypatch):
|
|
"""#96310 review: every byte fetch carries the tree's SHA as ?ref=."""
|
|
source = GitHubSource(GitHubAuth())
|
|
fetched: list[tuple[str, dict | None]] = []
|
|
|
|
def _fake_content(repo, path, ref=None):
|
|
fetched.append((path, {"ref": ref} if ref else None))
|
|
return SKILL_MD if path.endswith("SKILL.md") else "x"
|
|
|
|
monkeypatch.setattr(source, "_fetch_file_content", _fake_content)
|
|
monkeypatch.setattr(
|
|
source,
|
|
"_fetch_file_bytes",
|
|
lambda repo, path, ref=None: fetched.append((path, {"ref": ref} if ref else None)) or b"x",
|
|
)
|
|
source._tree_cache["owner/repo"] = (
|
|
"main",
|
|
[
|
|
{"path": "skill/SKILL.md", "type": "blob", "mode": "100644"},
|
|
{"path": "skill/CONTEXT-FORMAT.md", "type": "blob", "mode": "100644"},
|
|
],
|
|
)
|
|
source._tree_revisions["owner/repo"] = "treesha123"
|
|
|
|
minimal_skill = "---\nname: x\ndescription: x\n---\nSee [the format](CONTEXT-FORMAT.md).\n"
|
|
monkeypatch.setattr(
|
|
source, "_fetch_file_content",
|
|
lambda repo, path, ref=None: fetched.append((path, {"ref": ref} if ref else None)) or minimal_skill,
|
|
)
|
|
|
|
bundle = source.fetch("owner/repo/skill")
|
|
|
|
assert bundle is not None
|
|
assert fetched, "expected byte fetches"
|
|
for path, params in fetched:
|
|
assert params == {"ref": "treesha123"}, path
|
|
|
|
|
|
def test_github_source_rejects_symlink_in_referenced_directory(monkeypatch):
|
|
source = GitHubSource(GitHubAuth())
|
|
monkeypatch.setattr(source, "_fetch_file_content", lambda _repo, path, ref=None: SKILL_MD if path.endswith("SKILL.md") else "x")
|
|
source._tree_cache["owner/repo"] = (
|
|
"main",
|
|
[
|
|
{"path": "skill/SKILL.md", "type": "blob", "mode": "100644"},
|
|
{"path": "skill/references/guide.md", "type": "blob", "mode": "120000"},
|
|
],
|
|
)
|
|
|
|
assert source.fetch("owner/repo/skill") is None
|
|
|
|
|
|
def test_github_source_fetch_downloads_full_skill_directory(monkeypatch):
|
|
"""Support files a skill keeps outside SKILL.md-linked paths still install.
|
|
|
|
Regression for skills using non-canonical support dirs (impeccable keeps
|
|
everything under `reference/` singular and `scripts/` linked only from
|
|
reference files): the old link-driven fetch shipped SKILL.md alone.
|
|
"""
|
|
source = GitHubSource(GitHubAuth())
|
|
skill_md = (
|
|
"---\nname: full-dir\ndescription: d\n---\n"
|
|
"See [audit](reference/audit.md) and run `node scripts/pin.mjs`.\n"
|
|
)
|
|
fetched: list = []
|
|
monkeypatch.setattr(source, "_fetch_file_content", lambda _repo, path, ref=None: skill_md)
|
|
monkeypatch.setattr(
|
|
source, "_fetch_file_bytes",
|
|
lambda _repo, path, ref=None: fetched.append(path) or b"content-of-" + path.encode(),
|
|
)
|
|
source._tree_cache["owner/repo"] = (
|
|
"main",
|
|
[
|
|
{"path": "skill/SKILL.md", "type": "blob", "mode": "100644"},
|
|
{"path": "skill/reference/audit.md", "type": "blob", "mode": "100644"},
|
|
{"path": "skill/reference/deep/native.md", "type": "blob", "mode": "100644"},
|
|
{"path": "skill/scripts/pin.mjs", "type": "blob", "mode": "100644"},
|
|
{"path": "skill/LICENSE", "type": "blob", "mode": "100644"},
|
|
# skipped: symlink, hidden file, pyc, out-of-prefix
|
|
{"path": "skill/reference/link.md", "type": "blob", "mode": "120000"},
|
|
{"path": "skill/.hidden", "type": "blob", "mode": "100644"},
|
|
{"path": "skill/scripts/x.pyc", "type": "blob", "mode": "100644"},
|
|
{"path": "other/README.md", "type": "blob", "mode": "100644"},
|
|
],
|
|
)
|
|
|
|
bundle = source.fetch("owner/repo/skill")
|
|
|
|
assert bundle is not None
|
|
assert set(bundle.files) == {
|
|
"SKILL.md",
|
|
"reference/audit.md",
|
|
"reference/deep/native.md",
|
|
"scripts/pin.mjs",
|
|
"LICENSE",
|
|
}
|
|
|
|
|
|
def test_github_source_fetch_dangling_linked_reference_warns_not_aborts(monkeypatch):
|
|
"""A SKILL.md-linked references/ path absent from the tree installs
|
|
without the file (dangling links are prose over-matches / repo-only dev
|
|
tools — #66760/#90081); a SYMLINKED referenced path still hard-rejects."""
|
|
source = GitHubSource(GitHubAuth())
|
|
skill_md = (
|
|
"---\nname: dangling\ndescription: d\n---\n"
|
|
"Read [the guide](references/guide.md).\n"
|
|
)
|
|
monkeypatch.setattr(source, "_fetch_file_content", lambda _repo, path, ref=None: skill_md)
|
|
monkeypatch.setattr(source, "_fetch_file_bytes", lambda _repo, path, ref=None: b"x")
|
|
|
|
# Missing entirely -> installs without it.
|
|
source._tree_cache["owner/repo"] = (
|
|
"main",
|
|
[{"path": "skill/SKILL.md", "type": "blob", "mode": "100644"}],
|
|
)
|
|
bundle = source.fetch("owner/repo/skill")
|
|
assert bundle is not None
|
|
assert "references/guide.md" not in bundle.files
|
|
|
|
# Present as a symlink -> hard rejection.
|
|
source._tree_cache["owner/repo"] = (
|
|
"main",
|
|
[
|
|
{"path": "skill/SKILL.md", "type": "blob", "mode": "100644"},
|
|
{"path": "skill/references/guide.md", "type": "blob", "mode": "120000"},
|
|
],
|
|
)
|
|
assert source.fetch("owner/repo/skill") is None
|
|
|
|
|
|
def test_real_temp_repo_and_home_install_e2e(served_repo, monkeypatch, tmp_path):
|
|
from hermes_cli.skills_hub import do_install
|
|
|
|
_repo, url = served_repo
|
|
home = tmp_path / "home"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr("tools.skills_hub.is_safe_url", lambda _url: True)
|
|
monkeypatch.setattr("tools.skills_hub.check_website_access", lambda _url: None)
|
|
monkeypatch.setattr("tools.skills_hub_search.create_source_router", lambda auth=None: [UrlSource()])
|
|
|
|
sink = StringIO()
|
|
do_install(url, console=Console(file=sink, force_terminal=False), skip_confirm=True)
|
|
|
|
installed = home / "skills" / "demo-bundle"
|
|
assert (installed / "references" / "guide.md").read_text() == "safe guide\n"
|
|
assert (installed / "references" / "foo#bar.md").read_text() == "encoded delimiter\n"
|
|
assert (installed / "references" / "my guide.md").read_text() == "encoded space\n"
|
|
assert (installed / "templates" / "report.md").is_file()
|
|
assert (installed / "scripts" / "run.py").is_file()
|
|
assert (installed / "examples" / "endpoint-inventory.md").is_file()
|
|
assert not (installed / "examples" / "not-installed.md").exists()
|
|
assert (installed / "assets" / "logo.png").read_bytes() == b"\x89PNG\r\n\x1a\n\x00\xff"
|
|
entry = json.loads((home / "skills" / ".hub" / "lock.json").read_text())["installed"]["demo-bundle"]
|
|
assert entry["scan_provenance"]["source_url"] == url
|
|
assert entry["scan_provenance"]["fresh"] is True
|
|
|
|
|
|
def _make_skills_redirect(link: Path, target: Path) -> bool:
|
|
"""Make *link* a directory redirect (Windows junction or POSIX symlink)
|
|
pointing at *target*. Junctions need no admin rights, unlike symlinks."""
|
|
if sys.platform == "win32":
|
|
try:
|
|
subprocess.run(
|
|
["cmd", "/c", "mklink", "/J", str(link), str(target)],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
return True
|
|
except (subprocess.CalledProcessError, OSError):
|
|
return False
|
|
try:
|
|
link.symlink_to(target, target_is_directory=True)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def test_install_with_junctioned_skills_dir(served_repo, monkeypatch, tmp_path):
|
|
"""#86971: install must not mix resolved and unresolved paths when the
|
|
skills directory is a junction/symlink redirect.
|
|
|
|
install_dir is resolved by _resolve_lock_install_path (following the
|
|
redirect), so relative_to() must receive the resolved skills root or it
|
|
raises ValueError after the files have already been moved, leaving a lock
|
|
entry without a content_hash (which then poisons 'hermes skills check').
|
|
"""
|
|
from hermes_cli.skills_hub import do_install
|
|
|
|
_repo, url = served_repo
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
real_skills = tmp_path / "real-skills"
|
|
real_skills.mkdir()
|
|
skills_link = home / "skills"
|
|
if not _make_skills_redirect(skills_link, real_skills):
|
|
pytest.skip("Cannot create a junction/symlink in this environment")
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr("tools.skills_hub.is_safe_url", lambda _url: True)
|
|
monkeypatch.setattr("tools.skills_hub.check_website_access", lambda _url: None)
|
|
monkeypatch.setattr("tools.skills_hub_search.create_source_router", lambda auth=None: [UrlSource()])
|
|
|
|
sink = StringIO()
|
|
do_install(url, console=Console(file=sink, force_terminal=False), skip_confirm=True)
|
|
|
|
# Files landed in the real target, reached through the junction.
|
|
installed = real_skills / "demo-bundle"
|
|
assert (installed / "SKILL.md").is_file()
|
|
assert (installed / "references" / "guide.md").read_text() == "safe guide\n"
|
|
# Lock entry got a valid relative install_path AND the content hash — the
|
|
# record_install call the pre-fix ValueError used to skip.
|
|
entry = json.loads((home / "skills" / ".hub" / "lock.json").read_text())["installed"]["demo-bundle"]
|
|
assert entry["install_path"] == "demo-bundle"
|
|
assert entry["content_hash"].startswith("sha256:")
|
|
# The post-install "Installed:" line (relative_to on the display path)
|
|
# renders instead of raising.
|
|
assert "Installed:" in sink.getvalue()
|
|
|
|
|
|
SKILL_MD_MISSING_REF = """---
|
|
name: partial-bundle
|
|
description: References a support file that is unreachable.
|
|
---
|
|
# Partial
|
|
Read [the guide](references/present.md#usage) and the appendix at
|
|
`references/absent.md`, then run `scripts/run.py`.
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def served_repo_missing_support(tmp_path, monkeypatch):
|
|
"""Serve a skill whose SKILL.md references a support file that is not
|
|
present on the server, so fetching it returns 404."""
|
|
# Serve over loopback while opting this test server into private-address
|
|
# access, so the SSRF-safe HTTP client is exercised for real (see
|
|
# ``served_repo``).
|
|
monkeypatch.setattr("tools.url_safety._global_allow_private_urls", lambda: True)
|
|
|
|
repo = tmp_path / "upstream-missing"
|
|
repo.mkdir()
|
|
(repo / "SKILL.md").write_text(SKILL_MD_MISSING_REF)
|
|
# references/absent.md is deliberately NOT created, so the server 404s it.
|
|
for rel, content in {
|
|
"references/present.md": "present guide\n",
|
|
"scripts/run.py": "print('ok')\n",
|
|
}.items():
|
|
path = repo / rel
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(content, newline="\n")
|
|
|
|
server = ThreadingHTTPServer(
|
|
("127.0.0.1", 0), partial(_QuietHandler, directory=str(repo))
|
|
)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
yield repo, f"http://127.0.0.1:{server.server_port}/SKILL.md"
|
|
finally:
|
|
server.shutdown()
|
|
thread.join()
|
|
|
|
|
|
def test_install_skips_unreachable_support_file_e2e(served_repo_missing_support, monkeypatch, tmp_path):
|
|
"""A referenced support file that 404s is skipped rather than aborting the
|
|
whole URL install: the bundle still installs end-to-end through quarantine,
|
|
scan, install, and lock provenance, with only the reachable files landing
|
|
on disk and recorded in the lock file (#66760)."""
|
|
from hermes_cli.skills_hub import do_install
|
|
|
|
_repo, url = served_repo_missing_support
|
|
home = tmp_path / "home"
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr("tools.skills_hub.is_safe_url", lambda _url: True)
|
|
monkeypatch.setattr("tools.skills_hub.check_website_access", lambda _url: None)
|
|
monkeypatch.setattr("tools.skills_hub_search.create_source_router", lambda auth=None: [UrlSource()])
|
|
|
|
sink = StringIO()
|
|
do_install(url, console=Console(file=sink, force_terminal=False), skip_confirm=True)
|
|
|
|
installed = home / "skills" / "partial-bundle"
|
|
assert (installed / "SKILL.md").is_file()
|
|
assert (installed / "references" / "present.md").read_text() == "present guide\n"
|
|
assert (installed / "scripts" / "run.py").is_file()
|
|
# The unreachable reference neither blocked the install nor was written.
|
|
assert not (installed / "references" / "absent.md").exists()
|
|
|
|
entry = json.loads((home / "skills" / ".hub" / "lock.json").read_text())["installed"]["partial-bundle"]
|
|
assert entry["scan_provenance"]["source_url"] == url
|
|
assert "references/present.md" in entry["files"]
|
|
assert "references/absent.md" not in entry["files"]
|
|
|
|
|
|
def test_bundled_optional_source_still_includes_support_files(tmp_path, monkeypatch):
|
|
from tools.skills_hub_official import OptionalSkillSource
|
|
|
|
root = tmp_path / "optional-skills"
|
|
skill = root / "category" / "official-demo"
|
|
(skill / "references").mkdir(parents=True)
|
|
(skill / "SKILL.md").write_text("---\nname: official-demo\ndescription: demo\n---\n")
|
|
(skill / "references" / "all.md").write_text("all")
|
|
source = OptionalSkillSource()
|
|
source._optional_dir = root
|
|
|
|
bundle = source.fetch("official/category/official-demo")
|
|
assert bundle is not None
|
|
assert set(bundle.files) == {"SKILL.md", os.path.join("references", "all.md")}
|
|
|
|
|
|
UPSTREAM_STUB_MD = """---
|
|
name: upstream-demo
|
|
description: Upstream-maintained catalog entry.
|
|
metadata:
|
|
hermes:
|
|
upstream:
|
|
repo: acme/design-skill
|
|
path: .hermes/skills/design
|
|
---
|
|
# Stub
|
|
"""
|
|
|
|
|
|
def test_optional_source_upstream_stub_fetches_from_external_repo(tmp_path, monkeypatch):
|
|
"""A catalog stub with metadata.hermes.upstream installs the upstream repo's
|
|
content (relabelled official/trusted), not the stub itself."""
|
|
from tools.skills_hub_official import OptionalSkillSource
|
|
|
|
root = tmp_path / "optional-skills"
|
|
skill = root / "creative" / "upstream-demo"
|
|
skill.mkdir(parents=True)
|
|
(skill / "SKILL.md").write_text(UPSTREAM_STUB_MD)
|
|
|
|
source = OptionalSkillSource()
|
|
source._optional_dir = root
|
|
|
|
fetched_ids = []
|
|
|
|
class _FakeGitHub:
|
|
def fetch(self, identifier):
|
|
fetched_ids.append(identifier)
|
|
return SkillBundle(
|
|
name="design",
|
|
files={"SKILL.md": "---\nname: design\ndescription: real\n---\nreal body",
|
|
"reference/audit.md": "audit"},
|
|
source="github",
|
|
identifier=identifier,
|
|
trust_level="community",
|
|
metadata={"source_url": "https://github.com/acme/design-skill"},
|
|
)
|
|
|
|
monkeypatch.setattr(source, "_get_github", lambda: _FakeGitHub())
|
|
|
|
bundle = source.fetch("official/creative/upstream-demo")
|
|
|
|
assert fetched_ids == ["acme/design-skill/.hermes/skills/design"]
|
|
assert bundle is not None
|
|
assert bundle.source == "official"
|
|
assert bundle.identifier == "official/creative/upstream-demo"
|
|
assert bundle.trust_level == "trusted"
|
|
assert bundle.files["SKILL.md"].startswith("---\nname: design")
|
|
assert bundle.metadata["upstream_repo"] == "acme/design-skill"
|
|
|
|
|
|
def test_optional_source_upstream_pointer_rejects_malformed(tmp_path):
|
|
from tools.skills_hub_official import OptionalSkillSource
|
|
|
|
source = OptionalSkillSource()
|
|
bad = [
|
|
"---\nname: x\nmetadata:\n hermes:\n upstream:\n repo: acme\n path: skills/x\n---\n", # repo not owner/name
|
|
"---\nname: x\nmetadata:\n hermes:\n upstream:\n repo: a/b/c\n path: skills/x\n---\n", # repo too deep
|
|
"---\nname: x\nmetadata:\n hermes:\n upstream:\n repo: acme/skill\n path: ../../etc\n---\n", # traversal
|
|
"---\nname: x\nmetadata:\n hermes:\n upstream:\n repo: acme/skill\n---\n", # missing path
|
|
"---\nname: x\n---\n", # no pointer
|
|
]
|
|
for content in bad:
|
|
assert source._upstream_pointer_from_content(content) is None
|
|
|
|
|
|
def test_unified_search_trust_rank_survives_limit_cut():
|
|
"""Official/builtin results must survive the limit truncation even when a
|
|
high-volume community source floods the merged list first."""
|
|
from unittest.mock import patch as _patch
|
|
from tools.skills_hub_models import SkillMeta
|
|
from tools.skills_hub_search import unified_search
|
|
|
|
community = [
|
|
SkillMeta(name=f"s{i}", description="", source="skills.sh",
|
|
identifier=f"skills-sh/x/s{i}", trust_level="community")
|
|
for i in range(20)
|
|
]
|
|
official = [SkillMeta(name="s-official", description="", source="official",
|
|
identifier="official/cat/s-official", trust_level="builtin")]
|
|
|
|
with _patch("tools.skills_hub_search.parallel_search_sources",
|
|
return_value=(community + official, {}, [])):
|
|
results = unified_search("s", [], source_filter="all", limit=10)
|
|
|
|
assert results[0].identifier == "official/cat/s-official"
|