mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
d16c911750
* feat(skills): paste SKILL.md to auto-fill the Create Skill modal
When a user pastes an Anthropic-style SKILL.md (YAML frontmatter +
markdown body) into the Create Skill content textarea, the frontend
sniffs the leading ``---``, posts the raw text to a new backend parse
endpoint, and populates name / description / tags / author / version /
license / compatibility / allowed_tools from the parsed fields. The
textarea is left with the body only (frontmatter stripped), and a toast
reports how many fields were set vs. kept (already-typed values are
preserved).
Backend
- ``POST /v1/api/admin/skills/parse`` (admin.skills permission) wraps
the existing ``turnstone.core.skill_parser.parse_skill_md`` so admin
imports and external installs share one parser. ``ParseSkillRequest``
/ ``ParseSkillResponse`` schemas added; OpenAPI spec + sync/async
console SDK methods updated.
- Hardening: 32 KiB cap on ``raw`` (Pydantic ``max_length`` + handler
enforcement); ``Content-Length`` pre-check returns 413 before any body
buffering; parse offloaded via ``asyncio.to_thread`` so deeply-nested
YAML cannot stall the event loop.
Frontend (turnstone/console/static)
- New paste handler with optimistic paint (raw text shown immediately,
textarea disabled + ``aria-busy`` flipped, hint switches to
"Parsing...") so the round-trip is visible on slow networks.
- ``AbortController`` + generation guard (``_ctmPasteController``) so a
fresh paste or modal close cancels a stale fetch — the previous
handler's callbacks see the controller has been replaced and bail
before touching the DOM.
- Non-destructive overwrite: ``_setSkillFormField`` returns "filled" /
"skipped" / "absent" and refuses to clobber non-empty values. Toast
reports counts.
- Bumps ``#toast`` z-index above modal overlays (was 200 vs. modal 600
— toasts fired while a modal was open were invisible). Console-wide
fix exposed by this being the first feature to fire toasts mid-modal.
HTML / CSS
- New ``.skill-paste-hint`` line above the textarea announcing the
affordance, sized to match surrounding ``.label-hint`` text.
- ``aria-describedby`` ties the hint to the textarea; ``aria-live=
"polite"`` announces the busy-state transition to screen readers.
- "Skill Content" heading hint reworded "system message — ..." →
"available: ..." and the variables row label "Variables" → "Used"
to disambiguate available vs. in-use template variables.
Tests
- 11 new cases in ``tests/test_skill_parse_api.py``: happy paths
(full / minimal / nested-metadata / unquoted-colon recovery),
malformed YAML 400, missing/blank/missing-name 400, RBAC 403, raw
body 32 KiB cap (Content-Length pre-check), chunked-encoding bypass
forces the application-layer cap. Test pins ``raw_frontmatter``
omission so a future ``dataclasses.asdict`` refactor can't silently
leak the full YAML dict back to clients.
Validation
- 5146 / 5146 ``pytest -k "not live"`` pass.
- ``ruff`` + ``mypy`` clean on changed sources.
- ``node -c`` clean on governance.js.
- Two-stage code review (full pipeline + bug+quality re-review of the
fix patches) applied; all confirmed findings addressed.
* fix(skills): Copilot PR #477 review fixes (cumulative bug-1, bug-2, q-1)
bug-1 (server.py): Content-Length pre-check was clamped to 32 KiB —
the same number as the per-string char cap on ``raw``. A legitimate
``raw`` of exactly 32 KiB produces a JSON body well above 32 KiB once
the ``{"raw":"..."}`` wrapper and any escaping is added, so valid
near-max requests were 413'd. New constant
``_PARSE_SKILL_MAX_BODY_BYTES = _PARSE_SKILL_MAX_CHARS * 4`` admits the
wrapper + multibyte expansion while still refusing obviously oversized
payloads early; the per-string ``len(raw)`` check stays authoritative.
bug-2 (governance.js): hideCreateTemplateModal aborted the inflight
paste controller and nulled the global, but the handler's ``.catch``
and ``.finally`` guard each DOM mutation behind ``_isCurrent()`` —
both bail when the controller has been nulled, leaving the textarea
``disabled`` + ``aria-busy`` and the hint stuck on "Parsing…".
Reopening the modal landed on a poisoned state. The second-pass
review's q-2 cleanup that dropped the show-side defensive reset
missed this scenario — the verifier's reachability argument confused
"controller is null" with "UI state is reset"; the two are
independent. Hide now resets the paste-induced visible state
alongside the abort.
q-1 (console_spec.py): error_codes for the parse endpoint listed only
400; handler also returns 413 for oversized bodies. Added 413; kept
403 implicit per the convention sibling admin endpoints follow.
Test fixup: bumped the Content-Length test payload to 200 KB so it
clearly exceeds the new 128 KB pre-check threshold; otherwise it was
falling through to the per-string check and duplicating
test_oversized_raw_chunked_returns_413's coverage.
(cherry picked from commit 0a8083e6d5)
233 lines
7.8 KiB
Python
233 lines
7.8 KiB
Python
"""Tests for the SKILL.md parse admin API endpoint.
|
||
|
||
The endpoint is a thin permission-checked wrapper around
|
||
``turnstone.core.skill_parser.parse_skill_md``. These tests cover the
|
||
routing, auth, and error-handling layers — parser semantics live in
|
||
``test_skill_parser.py``.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
import pytest
|
||
from starlette.applications import Starlette
|
||
from starlette.middleware import Middleware
|
||
from starlette.middleware.base import BaseHTTPMiddleware
|
||
from starlette.routing import Mount, Route
|
||
from starlette.testclient import TestClient
|
||
|
||
if TYPE_CHECKING:
|
||
from collections.abc import Iterator
|
||
|
||
from starlette.requests import Request
|
||
from starlette.responses import Response
|
||
|
||
from turnstone.console.server import admin_parse_skill
|
||
from turnstone.core.auth import AuthResult
|
||
|
||
|
||
class _InjectAuthMiddleware(BaseHTTPMiddleware):
|
||
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
||
request.state.auth_result = AuthResult(
|
||
user_id="test-user",
|
||
scopes=frozenset({"approve"}),
|
||
token_source="config",
|
||
permissions=frozenset({"read", "write", "approve", "admin.skills"}),
|
||
)
|
||
return await call_next(request)
|
||
|
||
|
||
class _InjectAuthNoSkillsMiddleware(BaseHTTPMiddleware):
|
||
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
||
request.state.auth_result = AuthResult(
|
||
user_id="test-user",
|
||
scopes=frozenset({"approve"}),
|
||
token_source="jwt",
|
||
permissions=frozenset({"read", "write", "approve"}),
|
||
)
|
||
return await call_next(request)
|
||
|
||
|
||
_ROUTES = [
|
||
Mount(
|
||
"/v1",
|
||
routes=[
|
||
Route("/api/admin/skills/parse", admin_parse_skill, methods=["POST"]),
|
||
],
|
||
),
|
||
]
|
||
|
||
|
||
@pytest.fixture
|
||
def client() -> TestClient:
|
||
app = Starlette(
|
||
routes=_ROUTES,
|
||
middleware=[Middleware(_InjectAuthMiddleware)],
|
||
)
|
||
return TestClient(app)
|
||
|
||
|
||
@pytest.fixture
|
||
def client_no_perm() -> TestClient:
|
||
app = Starlette(
|
||
routes=_ROUTES,
|
||
middleware=[Middleware(_InjectAuthNoSkillsMiddleware)],
|
||
)
|
||
return TestClient(app)
|
||
|
||
|
||
_FULL_SKILL = """\
|
||
---
|
||
name: code-review
|
||
description: Automated code review skill
|
||
author: Test Author
|
||
version: 2.0.0
|
||
tags: [python, review, quality]
|
||
allowed-tools: [read_file, list_directory]
|
||
license: MIT
|
||
compatibility: ">=0.7"
|
||
---
|
||
|
||
# Code Review
|
||
|
||
Review code for best practices.
|
||
"""
|
||
|
||
_MINIMAL_SKILL = """\
|
||
---
|
||
name: minimal
|
||
---
|
||
|
||
Just some content.
|
||
"""
|
||
|
||
|
||
class TestParseSkill:
|
||
def test_parses_full_frontmatter(self, client: TestClient) -> None:
|
||
resp = client.post("/v1/api/admin/skills/parse", json={"raw": _FULL_SKILL})
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["name"] == "code-review"
|
||
assert data["description"] == "Automated code review skill"
|
||
assert data["author"] == "Test Author"
|
||
assert data["version"] == "2.0.0"
|
||
assert data["tags"] == ["python", "review", "quality"]
|
||
assert data["allowed_tools"] == ["read_file", "list_directory"]
|
||
assert data["license"] == "MIT"
|
||
assert data["compatibility"] == ">=0.7"
|
||
assert "# Code Review" in data["content"]
|
||
# Frontmatter should not leak into the body.
|
||
assert "name: code-review" not in data["content"]
|
||
# ParsedSkill carries raw_frontmatter (the full YAML dict) but the
|
||
# handler whitelists fields by hand to avoid leaking arbitrary keys.
|
||
# Pin that contract — a future refactor to dataclasses.asdict would
|
||
# silently break it without this assertion.
|
||
assert "raw_frontmatter" not in data
|
||
|
||
def test_parses_minimal_frontmatter(self, client: TestClient) -> None:
|
||
resp = client.post("/v1/api/admin/skills/parse", json={"raw": _MINIMAL_SKILL})
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["name"] == "minimal"
|
||
assert data["description"] == "Just some content."
|
||
assert data["version"] == "1.0.0"
|
||
assert data["tags"] == []
|
||
assert data["allowed_tools"] == []
|
||
assert data["license"] == ""
|
||
|
||
def test_anthropic_nested_metadata_tags(self, client: TestClient) -> None:
|
||
# Anthropic-style skill puts tags under metadata.tags rather than
|
||
# at the top level — the parser must handle both layouts.
|
||
raw = """\
|
||
---
|
||
name: nested-meta
|
||
description: A skill using nested metadata
|
||
metadata:
|
||
tags: [alpha, beta]
|
||
author: Anthropic
|
||
version: 3.1.4
|
||
---
|
||
|
||
Body.
|
||
"""
|
||
resp = client.post("/v1/api/admin/skills/parse", json={"raw": raw})
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["tags"] == ["alpha", "beta"]
|
||
assert data["author"] == "Anthropic"
|
||
assert data["version"] == "3.1.4"
|
||
|
||
def test_unquoted_colon_in_description(self, client: TestClient) -> None:
|
||
# Common cross-client mistake: ``description: Use when: the user...``
|
||
# The parser retries with the description value quoted.
|
||
raw = """\
|
||
---
|
||
name: colon-desc
|
||
description: Use when: the user asks for a review
|
||
---
|
||
|
||
Body.
|
||
"""
|
||
resp = client.post("/v1/api/admin/skills/parse", json={"raw": raw})
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["name"] == "colon-desc"
|
||
assert "Use when" in data["description"]
|
||
|
||
def test_missing_name_returns_400(self, client: TestClient) -> None:
|
||
raw = """\
|
||
---
|
||
description: No name field
|
||
---
|
||
|
||
Body.
|
||
"""
|
||
resp = client.post("/v1/api/admin/skills/parse", json={"raw": raw})
|
||
assert resp.status_code == 400
|
||
assert "name" in resp.json()["error"].lower()
|
||
|
||
def test_missing_raw_returns_400(self, client: TestClient) -> None:
|
||
resp = client.post("/v1/api/admin/skills/parse", json={})
|
||
assert resp.status_code == 400
|
||
assert "raw" in resp.json()["error"].lower()
|
||
|
||
def test_blank_raw_returns_400(self, client: TestClient) -> None:
|
||
resp = client.post("/v1/api/admin/skills/parse", json={"raw": " \n"})
|
||
assert resp.status_code == 400
|
||
|
||
def test_invalid_yaml_returns_400(self, client: TestClient) -> None:
|
||
# YAML that the malformed-description retry can't fix.
|
||
raw = "---\nname: [not, valid, here\n---\nBody.\n"
|
||
resp = client.post("/v1/api/admin/skills/parse", json={"raw": raw})
|
||
assert resp.status_code == 400
|
||
|
||
def test_requires_admin_skills_permission(self, client_no_perm: TestClient) -> None:
|
||
resp = client_no_perm.post("/v1/api/admin/skills/parse", json={"raw": _MINIMAL_SKILL})
|
||
assert resp.status_code == 403
|
||
|
||
def test_oversized_content_length_returns_413(self, client: TestClient) -> None:
|
||
# Content-Length pre-check rejects oversized bodies before they're
|
||
# buffered into memory. Caps worker memory against an admin-token
|
||
# holder spraying multi-GB JSON. The threshold is generous (~4×
|
||
# the per-string cap) so payload here must clearly exceed it.
|
||
oversized = "a" * 200_000
|
||
resp = client.post("/v1/api/admin/skills/parse", json={"raw": oversized})
|
||
assert resp.status_code == 413
|
||
|
||
def test_oversized_raw_chunked_returns_413(self, client: TestClient) -> None:
|
||
# When the client sends Transfer-Encoding: chunked there is no
|
||
# Content-Length header, so the pre-check is skipped and the
|
||
# application-layer cap is the only line of defence. httpx switches
|
||
# to chunked when the body is a generator.
|
||
def _gen() -> Iterator[bytes]:
|
||
yield b'{"raw":"' + b"a" * 33_000 + b'"}'
|
||
|
||
resp = client.post(
|
||
"/v1/api/admin/skills/parse",
|
||
content=_gen(),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
assert resp.status_code == 413
|
||
assert "raw" in resp.json()["error"].lower()
|