fix: retain ClawHub owner through version and bundle requests
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
"""Owner-qualified ClawHub fetches retain identity across actual HTTP requests."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import threading
|
||||
import zipfile
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from tools.skills_hub_clawhub import ClawHubSource
|
||||
|
||||
|
||||
@contextmanager
|
||||
def registry(*, fallback=False, mismatch=False):
|
||||
requests = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
url = urlsplit(self.path)
|
||||
query = parse_qs(url.query)
|
||||
requests.append((url.path, query))
|
||||
status = 200
|
||||
if query.get("owner") != ["alice"]:
|
||||
status, payload = 409, {"code": "AMBIGUOUS_SKILL_SLUG"}
|
||||
elif url.path.endswith("/download"):
|
||||
if fallback:
|
||||
status, payload = 404, {}
|
||||
else:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as archive:
|
||||
archive.writestr("SKILL.md", "# Alice fixture")
|
||||
payload = buf.getvalue()
|
||||
elif url.path.endswith("/versions"):
|
||||
payload = [{"version": "1.0"}]
|
||||
elif url.path.endswith("/versions/1.0"):
|
||||
payload = {"files": {"SKILL.md": "# Alice fixture"}}
|
||||
else:
|
||||
# Explicit owner must survive even when metadata omits it.
|
||||
payload = {"skill": {"slug": "collision"}}
|
||||
if mismatch:
|
||||
payload["owner"] = {"handle": "bob"}
|
||||
body = payload if isinstance(payload, bytes) else json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
source = ClawHubSource()
|
||||
source.BASE_URL = f"http://127.0.0.1:{server.server_port}/api/v1"
|
||||
try:
|
||||
yield source, requests
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_qualified_owner_survives_metadata_versions_and_download():
|
||||
for fallback in (False, True):
|
||||
with registry(fallback=fallback) as (source, requests):
|
||||
for identifier in ("@alice/collision", "clawhub/@alice/collision", "alice/skills/collision"):
|
||||
meta = source.inspect(identifier)
|
||||
assert meta is not None
|
||||
assert meta.identifier == "@alice/collision"
|
||||
bundle = source.fetch(identifier)
|
||||
assert bundle is not None
|
||||
assert bundle.identifier == "@alice/collision"
|
||||
assert bundle.files == {"SKILL.md": "# Alice fixture"}
|
||||
assert all(query.get("owner") == ["alice"] for _, query in requests)
|
||||
assert any(path.endswith("/versions") for path, _ in requests)
|
||||
assert any(path.endswith("/download") for path, _ in requests)
|
||||
if fallback:
|
||||
assert any(path.endswith("/versions/1.0") for path, _ in requests)
|
||||
|
||||
|
||||
def test_ambiguous_or_mismatched_owner_never_downloads():
|
||||
with registry() as (source, requests):
|
||||
assert source.fetch("collision") is None
|
||||
assert source.fetch("github-owner/repository/collision") is None
|
||||
assert len(requests) == 1
|
||||
with registry(mismatch=True) as (source, requests):
|
||||
assert source.fetch("@alice/collision") is None
|
||||
assert len(requests) == 1
|
||||
@@ -431,84 +431,6 @@ class TestClawHubSource(unittest.TestCase):
|
||||
self.assertIsNone(meta)
|
||||
mock_get.assert_called_once()
|
||||
|
||||
@patch("tools.skills_hub.httpx.get")
|
||||
def test_inspect_passes_owner_param_to_disambiguate_slug(self, mock_get):
|
||||
"""An @owner/slug identifier must reach the detail API as ?owner=.
|
||||
|
||||
ClawHub answers a slug claimed by multiple owners with 409
|
||||
AMBIGUOUS_SKILL_SLUG on the bare detail GET (#104117); the disambiguated
|
||||
form only resolves when the owner hint is forwarded as a query param."""
|
||||
mock_get.return_value = _MockResponse(
|
||||
status_code=200,
|
||||
json_data={
|
||||
"slug": "ponytail",
|
||||
"displayName": "ponytail",
|
||||
"summary": "Lazy senior dev mode",
|
||||
"owner": {"handle": "dietrichgebert"},
|
||||
"latestVersion": {"version": "1.0.0"},
|
||||
},
|
||||
)
|
||||
|
||||
meta = self.src.inspect("clawhub/@dietrichgebert/ponytail")
|
||||
|
||||
self.assertIsNotNone(meta)
|
||||
self.assertEqual(meta.extra.get("owner"), "dietrichgebert")
|
||||
args, kwargs = mock_get.call_args
|
||||
self.assertTrue(args[0].endswith("/skills/ponytail"))
|
||||
self.assertEqual(kwargs["params"], {"owner": "dietrichgebert"})
|
||||
|
||||
@patch("tools.skills_hub.httpx.get")
|
||||
def test_inspect_bare_slug_omits_owner_param(self, mock_get):
|
||||
"""Bare slugs keep the plain detail GET — no owner to forward."""
|
||||
mock_get.return_value = _MockResponse(
|
||||
status_code=200,
|
||||
json_data={
|
||||
"slug": "caldav-calendar",
|
||||
"displayName": "CalDAV Calendar",
|
||||
"summary": "Calendar integration",
|
||||
"latestVersion": {"version": "1.0.0"},
|
||||
},
|
||||
)
|
||||
|
||||
meta = self.src.inspect("caldav-calendar")
|
||||
|
||||
self.assertIsNotNone(meta)
|
||||
_, kwargs = mock_get.call_args
|
||||
self.assertIsNone(kwargs.get("params"))
|
||||
|
||||
@patch("tools.skills_hub._ssrf_safe_http_get")
|
||||
@patch("tools.skills_hub.httpx.get")
|
||||
def test_fetch_qualified_slug_resolves_ambiguous_slug_end_to_end(self, mock_get, mock_safe_get):
|
||||
"""install clawhub/@owner/slug must succeed for a multi-owner slug:
|
||||
the detail GET carries ?owner=, so ClawHub never answers 409."""
|
||||
def side_effect(url, *args, **kwargs):
|
||||
if url.endswith("/skills/ponytail"):
|
||||
return _MockResponse(
|
||||
status_code=200,
|
||||
json_data={
|
||||
"slug": "ponytail",
|
||||
"owner": {"handle": "dietrichgebert"},
|
||||
"latestVersion": {"version": "1.0.0"},
|
||||
},
|
||||
)
|
||||
if url.endswith("/skills/ponytail/versions/1.0.0"):
|
||||
return _MockResponse(
|
||||
status_code=200,
|
||||
json_data={"files": {"SKILL.md": "# Skill"}},
|
||||
)
|
||||
return _MockResponse(status_code=404, json_data={})
|
||||
|
||||
mock_get.side_effect = side_effect
|
||||
mock_safe_get.return_value = _MockResponse(status_code=200, text="# Skill")
|
||||
|
||||
bundle = self.src.fetch("@dietrichgebert/ponytail")
|
||||
|
||||
self.assertIsNotNone(bundle)
|
||||
self.assertEqual(bundle.name, "ponytail")
|
||||
self.assertEqual(bundle.files["SKILL.md"], "# Skill")
|
||||
detail_args, detail_kwargs = mock_get.call_args_list[0]
|
||||
self.assertTrue(detail_args[0].endswith("/skills/ponytail"))
|
||||
self.assertEqual(detail_kwargs["params"], {"owner": "dietrichgebert"})
|
||||
|
||||
|
||||
class TestClawHubCatalogWalkBounded(unittest.TestCase):
|
||||
|
||||
Reference in New Issue
Block a user