From 28a6b0dd330530c57efeeee06fffb575c1ecfd9b Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Tue, 17 Mar 2026 00:03:52 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20skill=20resources=20=E2=80=94=20API,=20?= =?UTF-8?q?admin=20UI,=20runtime=20injection,=20and=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the resource surface for skills (scripts/, references/, assets/): - 4 admin API endpoints: list, get, create, delete skill resources - Storage: delete_skill_resource_by_path + count_skill_resources_bulk - Admin UI: resource count badge in skills table, resource sections in create/edit modals with add/delete, readonly guard for installed skills - Runtime: _load_skills populates skill resources, _init_system_messages injects catalog (inlined if <8KB) - Python SDK: list/create/delete_skill_resource (async + sync) - TypeScript SDK: listSkillResources, createSkillResource, deleteSkillResource - Path traversal protection (normpath + .. rejection + null byte check) - Block empty skill discover searches (frontend toast + backend 400) - Rename MCP "Registry" tab to "Discover" for consistency with skills - Move Skills + MCP Servers into new "Extensions" sidebar group - 25 tests (7 storage, 16 API + 2 security) --- sdk/typescript/src/console.ts | 27 +++ sdk/typescript/src/index.ts | 3 + sdk/typescript/src/types.ts | 20 ++ tests/test_skill_resources_api.py | 298 +++++++++++++++++++++++++ tests/test_skill_resources_storage.py | 66 ++++++ turnstone/api/console_schemas.py | 25 +++ turnstone/api/console_spec.py | 38 ++++ turnstone/console/server.py | 217 +++++++++++++++++- turnstone/console/static/governance.js | 296 +++++++++++++++++++++++- turnstone/console/static/index.html | 36 ++- turnstone/core/session.py | 36 +++ turnstone/core/storage/_postgresql.py | 27 +++ turnstone/core/storage/_protocol.py | 8 + turnstone/core/storage/_sqlite.py | 27 +++ turnstone/sdk/console.py | 44 ++++ 15 files changed, 1159 insertions(+), 9 deletions(-) create mode 100644 tests/test_skill_resources_api.py create mode 100644 tests/test_skill_resources_storage.py diff --git a/sdk/typescript/src/console.ts b/sdk/typescript/src/console.ts index 8d23faee..0cfa3c2f 100644 --- a/sdk/typescript/src/console.ts +++ b/sdk/typescript/src/console.ts @@ -21,6 +21,7 @@ import type { CreateRoleOptions, CreateScheduleRequest, CreateSkillRequest, + CreateSkillResourceRequest, ImportMcpConfigResponse, ListAdminMemoriesResponse, ListMcpServersResponse, @@ -28,6 +29,7 @@ import type { ListSchedulesResponse, ListSettingSchemaResponse, ListSettingsResponse, + ListSkillResourcesResponse, ListSkillsResponse, McpServerDetail, RegistryInstallRequest, @@ -35,6 +37,7 @@ import type { SkillDiscoverResponse, SkillInfo, SkillInstallRequest, + SkillResourceInfo, NodeDetailResponse, NodesOptions, OrgInfo, @@ -293,6 +296,30 @@ export class TurnstoneConsole extends BaseClient { await this.request("DELETE", `/v1/api/admin/skills/${skillId}`); } + async listSkillResources(skillId: string): Promise { + const resp = await this.request( + "GET", + `/v1/api/admin/skills/${skillId}/resources`, + ); + return resp.resources; + } + + async createSkillResource( + skillId: string, + body: CreateSkillResourceRequest, + ): Promise { + return this.request("POST", `/v1/api/admin/skills/${skillId}/resources`, { + json: body, + }); + } + + async deleteSkillResource(skillId: string, path: string): Promise { + await this.request( + "DELETE", + `/v1/api/admin/skills/${skillId}/resources/${encodeURIComponent(path)}`, + ); + } + // -- Governance: Usage & Audit ---------------------------------------------- async getUsage(opts: UsageQueryOptions): Promise { diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index c68b5cbd..26dbc478 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -131,6 +131,9 @@ export type { CreateSkillRequest, UpdateSkillRequest, ListSkillsResponse, + SkillResourceInfo, + ListSkillResourcesResponse, + CreateSkillResourceRequest, UsageBreakdownItem, UsageResponse, UsageQueryOptions, diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index b1bc34b0..4aaee8c6 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -187,6 +187,7 @@ export interface SkillInfo { notify_on_complete: string; enabled: boolean; allowed_tools: string; + resource_count: number; created: string; updated: string; } @@ -242,6 +243,25 @@ export interface ListSkillsResponse { skills: SkillInfo[]; } +export interface SkillResourceInfo { + resource_id: string; + skill_id: string; + path: string; + content_type: string; + size: number; + created: string; +} + +export interface ListSkillResourcesResponse { + resources: SkillResourceInfo[]; +} + +export interface CreateSkillResourceRequest { + path: string; + content: string; + content_type?: string; +} + // --------------------------------------------------------------------------- // Server API — Health // --------------------------------------------------------------------------- diff --git a/tests/test_skill_resources_api.py b/tests/test_skill_resources_api.py new file mode 100644 index 00000000..42b29dfa --- /dev/null +++ b/tests/test_skill_resources_api.py @@ -0,0 +1,298 @@ +"""Tests for skill resource admin API endpoints.""" + +from __future__ import annotations + +import uuid +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 starlette.requests import Request + from starlette.responses import Response + +from turnstone.console.server import ( + admin_create_skill_resource, + admin_delete_skill_resource, + admin_get_skill, + admin_get_skill_resource, + admin_list_skill_resources, + admin_list_skills, +) +from turnstone.core.auth import AuthResult +from turnstone.core.storage._sqlite import SQLiteBackend + +# --------------------------------------------------------------------------- +# Auth middleware +# --------------------------------------------------------------------------- + + +class _InjectAuthMiddleware(BaseHTTPMiddleware): + """Inject an admin auth result with admin.skills permission.""" + + 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) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_ROUTES = [ + Mount( + "/v1", + routes=[ + Route("/api/admin/skills", admin_list_skills), + Route("/api/admin/skills/{skill_id}", admin_get_skill), + Route( + "/api/admin/skills/{skill_id}/resources", + admin_list_skill_resources, + ), + Route( + "/api/admin/skills/{skill_id}/resources", + admin_create_skill_resource, + methods=["POST"], + ), + Route( + "/api/admin/skills/{skill_id}/resources/{path:path}", + admin_get_skill_resource, + ), + Route( + "/api/admin/skills/{skill_id}/resources/{path:path}", + admin_delete_skill_resource, + methods=["DELETE"], + ), + ], + ), +] + + +@pytest.fixture() +def storage(tmp_path): + return SQLiteBackend(str(tmp_path / "test.db")) + + +@pytest.fixture() +def client(storage): + app = Starlette( + routes=_ROUTES, + middleware=[Middleware(_InjectAuthMiddleware)], + ) + app.state.auth_storage = storage + return TestClient(app) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _create_test_skill(storage: SQLiteBackend, *, readonly: bool = False) -> str: + """Create a minimal skill in storage and return its template_id.""" + skill_id = uuid.uuid4().hex + storage.create_prompt_template( + template_id=skill_id, + name=f"test-skill-{skill_id[:8]}", + category="general", + content="Test skill content.", + variables="[]", + is_default=False, + org_id="", + created_by="test", + readonly=readonly, + ) + return skill_id + + +# --------------------------------------------------------------------------- +# Tests: List resources +# --------------------------------------------------------------------------- + + +class TestListSkillResources: + def test_list_empty(self, client, storage): + skill_id = _create_test_skill(storage) + resp = client.get(f"/v1/api/admin/skills/{skill_id}/resources") + assert resp.status_code == 200 + data = resp.json() + assert data["resources"] == [] + + def test_list_with_resources(self, client, storage): + skill_id = _create_test_skill(storage) + storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "content") + resp = client.get(f"/v1/api/admin/skills/{skill_id}/resources") + assert resp.status_code == 200 + resources = resp.json()["resources"] + assert len(resources) == 1 + assert resources[0]["path"] == "scripts/a.sh" + assert "content" not in resources[0] # Content NOT in list view + + def test_skill_not_found(self, client): + resp = client.get("/v1/api/admin/skills/nonexistent/resources") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Tests: Create resource +# --------------------------------------------------------------------------- + + +class TestCreateSkillResource: + def test_create_valid(self, client, storage): + skill_id = _create_test_skill(storage) + resp = client.post( + f"/v1/api/admin/skills/{skill_id}/resources", + json={"path": "scripts/setup.sh", "content": "#!/bin/bash\necho hello"}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["path"] == "scripts/setup.sh" + assert data["size"] > 0 + + def test_invalid_path(self, client, storage): + skill_id = _create_test_skill(storage) + resp = client.post( + f"/v1/api/admin/skills/{skill_id}/resources", + json={"path": "malicious/file.sh", "content": "x"}, + ) + assert resp.status_code == 400 + + def test_path_traversal_rejected(self, client, storage): + skill_id = _create_test_skill(storage) + resp = client.post( + f"/v1/api/admin/skills/{skill_id}/resources", + json={"path": "scripts/../../etc/passwd", "content": "x"}, + ) + assert resp.status_code == 400 + + def test_null_byte_in_path_rejected(self, client, storage): + skill_id = _create_test_skill(storage) + resp = client.post( + f"/v1/api/admin/skills/{skill_id}/resources", + json={"path": "scripts/a\x00.sh", "content": "x"}, + ) + assert resp.status_code == 400 + + def test_duplicate_409(self, client, storage): + skill_id = _create_test_skill(storage) + storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "content") + resp = client.post( + f"/v1/api/admin/skills/{skill_id}/resources", + json={"path": "scripts/a.sh", "content": "new"}, + ) + assert resp.status_code == 409 + + def test_size_cap(self, client, storage): + skill_id = _create_test_skill(storage) + resp = client.post( + f"/v1/api/admin/skills/{skill_id}/resources", + json={"path": "scripts/big.sh", "content": "x" * (100 * 1024 + 1)}, + ) + assert resp.status_code == 400 + + def test_max_count(self, client, storage): + skill_id = _create_test_skill(storage) + for i in range(10): + storage.create_skill_resource(uuid.uuid4().hex, skill_id, f"scripts/s{i}.sh", "content") + resp = client.post( + f"/v1/api/admin/skills/{skill_id}/resources", + json={"path": "scripts/extra.sh", "content": "x"}, + ) + assert resp.status_code == 400 + + def test_readonly_skill_blocked(self, client, storage): + skill_id = _create_test_skill(storage, readonly=True) + resp = client.post( + f"/v1/api/admin/skills/{skill_id}/resources", + json={"path": "scripts/a.sh", "content": "x"}, + ) + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# Tests: Get resource +# --------------------------------------------------------------------------- + + +class TestGetSkillResource: + def test_get_existing(self, client, storage): + skill_id = _create_test_skill(storage) + storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "hello world") + resp = client.get( + f"/v1/api/admin/skills/{skill_id}/resources/scripts/a.sh", + ) + assert resp.status_code == 200 + data = resp.json() + assert data["content"] == "hello world" + assert data["path"] == "scripts/a.sh" + + def test_not_found(self, client, storage): + skill_id = _create_test_skill(storage) + resp = client.get( + f"/v1/api/admin/skills/{skill_id}/resources/scripts/nope.sh", + ) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Tests: Delete resource +# --------------------------------------------------------------------------- + + +class TestDeleteSkillResource: + def test_delete_existing(self, client, storage): + skill_id = _create_test_skill(storage) + storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "content") + resp = client.delete( + f"/v1/api/admin/skills/{skill_id}/resources/scripts/a.sh", + ) + assert resp.status_code == 200 + assert storage.get_skill_resource(skill_id, "scripts/a.sh") is None + + def test_not_found(self, client, storage): + skill_id = _create_test_skill(storage) + resp = client.delete( + f"/v1/api/admin/skills/{skill_id}/resources/scripts/nope.sh", + ) + assert resp.status_code == 404 + + def test_readonly_blocked(self, client, storage): + skill_id = _create_test_skill(storage, readonly=True) + storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "content") + resp = client.delete( + f"/v1/api/admin/skills/{skill_id}/resources/scripts/a.sh", + ) + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# Tests: Resource count in skill responses +# --------------------------------------------------------------------------- + + +class TestResourceCountInSkillResponse: + def test_list_includes_count(self, client, storage): + skill_id = _create_test_skill(storage) + storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "a") + storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/b.sh", "b") + resp = client.get("/v1/api/admin/skills") + skills = resp.json()["skills"] + skill = [s for s in skills if s["template_id"] == skill_id][0] + assert skill["resource_count"] == 2 + + def test_get_includes_count(self, client, storage): + skill_id = _create_test_skill(storage) + storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "a") + resp = client.get(f"/v1/api/admin/skills/{skill_id}") + assert resp.json()["resource_count"] == 1 diff --git a/tests/test_skill_resources_storage.py b/tests/test_skill_resources_storage.py new file mode 100644 index 00000000..0e289ec5 --- /dev/null +++ b/tests/test_skill_resources_storage.py @@ -0,0 +1,66 @@ +"""Tests for skill resource storage operations.""" + +from __future__ import annotations + +import uuid + +import pytest + +from turnstone.core.storage._sqlite import SQLiteBackend + + +@pytest.fixture() +def storage(tmp_path): + """Fresh SQLite backend for each test.""" + return SQLiteBackend(str(tmp_path / "test.db")) + + +class TestDeleteSkillResourceByPath: + def test_delete_existing(self, storage): + skill_id = uuid.uuid4().hex + rid = uuid.uuid4().hex + storage.create_skill_resource(rid, skill_id, "scripts/a.sh", "#!/bin/bash") + assert storage.delete_skill_resource_by_path(skill_id, "scripts/a.sh") is True + assert storage.get_skill_resource(skill_id, "scripts/a.sh") is None + + def test_delete_not_found(self, storage): + assert storage.delete_skill_resource_by_path("nonexistent", "scripts/a.sh") is False + + def test_delete_wrong_path(self, storage): + skill_id = uuid.uuid4().hex + rid = uuid.uuid4().hex + storage.create_skill_resource(rid, skill_id, "scripts/a.sh", "content") + assert storage.delete_skill_resource_by_path(skill_id, "scripts/b.sh") is False + # Original still exists + assert storage.get_skill_resource(skill_id, "scripts/a.sh") is not None + + def test_delete_only_target(self, storage): + """Deleting one resource doesn't affect others for the same skill.""" + skill_id = uuid.uuid4().hex + storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "a") + storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/b.sh", "b") + assert storage.delete_skill_resource_by_path(skill_id, "scripts/a.sh") is True + assert storage.get_skill_resource(skill_id, "scripts/b.sh") is not None + assert len(storage.list_skill_resources(skill_id)) == 1 + + +class TestListSkillResources: + def test_ordering(self, storage): + skill_id = uuid.uuid4().hex + storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/z.sh", "z") + storage.create_skill_resource(uuid.uuid4().hex, skill_id, "assets/a.txt", "a") + storage.create_skill_resource(uuid.uuid4().hex, skill_id, "references/m.md", "m") + rows = storage.list_skill_resources(skill_id) + paths = [r["path"] for r in rows] + assert paths == sorted(paths) + + def test_empty(self, storage): + assert storage.list_skill_resources("nonexistent") == [] + + def test_size_from_content(self, storage): + skill_id = uuid.uuid4().hex + content = "x" * 500 + storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", content) + rows = storage.list_skill_resources(skill_id) + assert len(rows) == 1 + assert len(rows[0]["content"]) == 500 diff --git a/turnstone/api/console_schemas.py b/turnstone/api/console_schemas.py index 9969ca07..f965ff75 100644 --- a/turnstone/api/console_schemas.py +++ b/turnstone/api/console_schemas.py @@ -310,6 +310,7 @@ class SkillInfo(BaseModel): scan_status: str = "" scan_report: str = "{}" scan_version: str = "" + resource_count: int = 0 created: str updated: str @@ -383,6 +384,30 @@ class ListSkillVersionsResponse(BaseModel): versions: list[SkillVersionInfo] +# --------------------------------------------------------------------------- +# Governance: Skill Resources +# --------------------------------------------------------------------------- + + +class SkillResourceInfo(BaseModel): + resource_id: str + skill_id: str + path: str + content_type: str = "text/plain" + size: int = 0 + created: str + + +class ListSkillResourcesResponse(BaseModel): + resources: list[SkillResourceInfo] + + +class CreateSkillResourceRequest(BaseModel): + path: str + content: str + content_type: str = "text/plain" + + # --------------------------------------------------------------------------- # Governance: Usage # --------------------------------------------------------------------------- diff --git a/turnstone/api/console_spec.py b/turnstone/api/console_spec.py index c8f1f157..f7132efe 100644 --- a/turnstone/api/console_spec.py +++ b/turnstone/api/console_spec.py @@ -23,6 +23,7 @@ from turnstone.api.console_schemas import ( CreateMcpServerRequest, CreateRoleRequest, CreateSkillRequest, + CreateSkillResourceRequest, CreateToolPolicyRequest, ImportMcpConfigRequest, ImportMcpConfigResponse, @@ -35,6 +36,7 @@ from turnstone.api.console_schemas import ( ListRolesResponse, ListSettingSchemaResponse, ListSettingsResponse, + ListSkillResourcesResponse, ListSkillsResponse, ListSkillVersionsResponse, ListToolPoliciesResponse, @@ -53,6 +55,7 @@ from turnstone.api.console_schemas import ( SkillDiscoverResponse, SkillInfo, SkillInstallRequest, + SkillResourceInfo, SkillVersionInfo, ToolPolicyInfo, UpdateMcpServerRequest, @@ -639,6 +642,38 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [ "Re-scan a skill for security signals", tags=["Admin"], ), + # --- Governance: Skill Resources --- + EndpointSpec( + "/v1/api/admin/skills/{skill_id}/resources", + "GET", + "List resource files for a skill", + response_model=ListSkillResourcesResponse, + tags=["Admin"], + ), + EndpointSpec( + "/v1/api/admin/skills/{skill_id}/resources", + "POST", + "Upload a resource file to a skill", + request_model=CreateSkillResourceRequest, + response_model=SkillResourceInfo, + error_codes=[400, 404, 409], + tags=["Admin"], + ), + EndpointSpec( + "/v1/api/admin/skills/{skill_id}/resources/{path:path}", + "GET", + "Get a single skill resource by path", + response_model=SkillResourceInfo, + error_codes=[404], + tags=["Admin"], + ), + EndpointSpec( + "/v1/api/admin/skills/{skill_id}/resources/{path:path}", + "DELETE", + "Delete a skill resource by path", + error_codes=[404], + tags=["Admin"], + ), # --- Admin: Memories --- EndpointSpec( "/v1/api/admin/memories", @@ -891,6 +926,9 @@ _ALL_MODELS: list[type[BaseModel]] = [ UpdateSkillRequest, ListSkillsResponse, ListSkillVersionsResponse, + SkillResourceInfo, + CreateSkillResourceRequest, + ListSkillResourcesResponse, SkillSummary, ListSkillSummaryResponse, ] diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 60af9080..81c49035 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -2248,7 +2248,7 @@ def _parse_skill_session_config(body: dict[str, Any]) -> tuple[dict[str, Any], J return fields, None -def _skill_to_response(r: dict[str, Any]) -> dict[str, Any]: +def _skill_to_response(r: dict[str, Any], resource_count: int = 0) -> dict[str, Any]: """Convert a storage skill dict to a JSON-safe response dict.""" import contextlib import json as _json @@ -2289,6 +2289,7 @@ def _skill_to_response(r: dict[str, Any]) -> dict[str, Any]: "scan_status": r.get("scan_status", ""), "scan_report": r.get("scan_report", "{}"), "scan_version": r.get("scan_version", ""), + "resource_count": resource_count, "created": r.get("created", ""), "updated": r.get("updated", ""), } @@ -2310,7 +2311,9 @@ async def admin_list_skills(request: Request) -> JSONResponse: offset = _parse_int(params, "offset", 0, minimum=0, maximum=100000) rows = storage.list_prompt_templates(limit=limit, offset=offset) total = storage.count_prompt_templates() - skills = [_skill_to_response(r) for r in rows] + skill_ids = [r["template_id"] for r in rows] + rc_map = storage.count_skill_resources_bulk(skill_ids) if skill_ids else {} + skills = [_skill_to_response(r, resource_count=rc_map.get(r["template_id"], 0)) for r in rows] return JSONResponse({"skills": skills, "total": total}) @@ -2330,7 +2333,8 @@ async def admin_get_skill(request: Request) -> JSONResponse: skill = storage.get_prompt_template(skill_id) if skill is None: return JSONResponse({"error": "Skill not found"}, status_code=404) - return JSONResponse(_skill_to_response(skill)) + rc = len(storage.list_skill_resources(skill_id)) + return JSONResponse(_skill_to_response(skill, resource_count=rc)) async def admin_create_skill(request: Request) -> JSONResponse: @@ -2829,6 +2833,192 @@ async def admin_rescan_skill(request: Request) -> JSONResponse: ) +# --------------------------------------------------------------------------- +# Admin: Skill Resources +# --------------------------------------------------------------------------- + +_ALLOWED_RESOURCE_DIRS = ("scripts/", "references/", "assets/") +_MAX_RESOURCE_SIZE = 100 * 1024 # 100KB +_MAX_RESOURCES_PER_SKILL = 10 + + +async def admin_list_skill_resources(request: Request) -> JSONResponse: + """GET /v1/api/admin/skills/{skill_id}/resources — list resources.""" + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.skills") + if err: + return err + + skill_id = request.path_params["skill_id"] + skill = storage.get_prompt_template(skill_id) + if skill is None: + return JSONResponse({"error": "Skill not found"}, status_code=404) + + rows = storage.list_skill_resources(skill_id) + resources = [ + { + "resource_id": r.get("resource_id", ""), + "skill_id": r.get("skill_id", ""), + "path": r.get("path", ""), + "content_type": r.get("content_type", "text/plain"), + "size": len(r.get("content", "")), + "created": r.get("created", ""), + } + for r in rows + ] + return JSONResponse({"resources": resources}) + + +async def admin_get_skill_resource(request: Request) -> JSONResponse: + """GET /v1/api/admin/skills/{skill_id}/resources/{path:path} — get one resource.""" + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.skills") + if err: + return err + + skill_id = request.path_params["skill_id"] + path = request.path_params["path"] + resource = storage.get_skill_resource(skill_id, path) + if resource is None: + return JSONResponse({"error": "Resource not found"}, status_code=404) + return JSONResponse( + { + "resource_id": resource.get("resource_id", ""), + "skill_id": resource.get("skill_id", ""), + "path": resource.get("path", ""), + "content": resource.get("content", ""), + "content_type": resource.get("content_type", "text/plain"), + "size": len(resource.get("content", "")), + "created": resource.get("created", ""), + } + ) + + +async def admin_create_skill_resource(request: Request) -> JSONResponse: + """POST /v1/api/admin/skills/{skill_id}/resources — upload resource.""" + import uuid + + from turnstone.core.audit import record_audit + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.skills") + if err: + return err + + skill_id = request.path_params["skill_id"] + skill = storage.get_prompt_template(skill_id) + if skill is None: + return JSONResponse({"error": "Skill not found"}, status_code=404) + if skill.get("readonly"): + return JSONResponse({"error": "Installed skills are read-only"}, status_code=403) + + body = await read_json_or_400(request) + if isinstance(body, JSONResponse): + return body + + path = str(body.get("path", "")).strip() + content = str(body.get("content", "")) + content_type = str(body.get("content_type", "text/plain")).strip()[:64] + + if not path: + return JSONResponse({"error": "path is required"}, status_code=400) + # Normalize and reject path traversal + import posixpath + + path = posixpath.normpath(path) + if ".." in path.split("/") or "\x00" in path: + return JSONResponse({"error": "Invalid path"}, status_code=400) + if not any(path.startswith(d.rstrip("/")) for d in _ALLOWED_RESOURCE_DIRS): + return JSONResponse( + {"error": "path must start with scripts/, references/, or assets/"}, + status_code=400, + ) + if len(content) > _MAX_RESOURCE_SIZE: + return JSONResponse( + {"error": f"Resource exceeds {_MAX_RESOURCE_SIZE // 1024}KB limit"}, + status_code=400, + ) + + existing = storage.list_skill_resources(skill_id) + if len(existing) >= _MAX_RESOURCES_PER_SKILL: + return JSONResponse( + {"error": f"Maximum {_MAX_RESOURCES_PER_SKILL} resources per skill"}, + status_code=400, + ) + if storage.get_skill_resource(skill_id, path) is not None: + return JSONResponse({"error": "Resource path already exists"}, status_code=409) + + resource_id = uuid.uuid4().hex + storage.create_skill_resource( + resource_id=resource_id, + skill_id=skill_id, + path=path, + content=content, + content_type=content_type, + ) + + audit_uid, ip = _audit_context(request) + record_audit(storage, audit_uid, "skill_resource.create", "skill", skill_id, {"path": path}, ip) + + created = storage.get_skill_resource(skill_id, path) + return JSONResponse( + { + "resource_id": resource_id, + "skill_id": skill_id, + "path": path, + "content_type": content_type, + "size": len(content), + "created": (created or {}).get("created", ""), + }, + status_code=201, + ) + + +async def admin_delete_skill_resource(request: Request) -> JSONResponse: + """DELETE /v1/api/admin/skills/{skill_id}/resources/{path:path} — delete resource.""" + from turnstone.core.audit import record_audit + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.skills") + if err: + return err + + skill_id = request.path_params["skill_id"] + skill = storage.get_prompt_template(skill_id) + if skill is None: + return JSONResponse({"error": "Skill not found"}, status_code=404) + if skill.get("readonly"): + return JSONResponse({"error": "Installed skills are read-only"}, status_code=403) + + path = request.path_params["path"] + deleted = storage.delete_skill_resource_by_path(skill_id, path) + if not deleted: + return JSONResponse({"error": "Resource not found"}, status_code=404) + + audit_uid, ip = _audit_context(request) + record_audit(storage, audit_uid, "skill_resource.delete", "skill", skill_id, {"path": path}, ip) + + return JSONResponse({"status": "ok"}) + + # --------------------------------------------------------------------------- # Admin: Skill Discovery # --------------------------------------------------------------------------- @@ -2870,6 +3060,8 @@ async def admin_skill_discover(request: Request) -> JSONResponse: return err q = str(request.query_params.get("q", "")).strip() + if not q: + return JSONResponse({"error": "Search query is required"}, status_code=400) try: limit = max(1, min(int(request.query_params.get("limit", "20")), 100)) except (ValueError, TypeError): @@ -4361,6 +4553,25 @@ def create_app( "/api/admin/skills/{skill_id}/versions", admin_list_skill_versions, ), + # Governance: Skill Resources + Route( + "/api/admin/skills/{skill_id}/resources", + admin_list_skill_resources, + ), + Route( + "/api/admin/skills/{skill_id}/resources", + admin_create_skill_resource, + methods=["POST"], + ), + Route( + "/api/admin/skills/{skill_id}/resources/{path:path}", + admin_get_skill_resource, + ), + Route( + "/api/admin/skills/{skill_id}/resources/{path:path}", + admin_delete_skill_resource, + methods=["DELETE"], + ), # Governance: Memories Route("/api/admin/memories", admin_list_memories), Route("/api/admin/memories/search", admin_search_memories), diff --git a/turnstone/console/static/governance.js b/turnstone/console/static/governance.js index b468c338..32008e34 100644 --- a/turnstone/console/static/governance.js +++ b/turnstone/console/static/governance.js @@ -14,6 +14,7 @@ var _govAuditOffset = 0; var _skillCurrentView = "installed"; var _skillDiscoverResults = []; var _skillDiscoverQuery = ""; +var _pendingResources = []; var _giTrapHandler = null; var _giTriggerEl = null; @@ -731,6 +732,15 @@ function _renderGovSkills(items) { escapeHtml(t.scan_status) + ""; } + var resBadge = ""; + if (t.resource_count > 0) { + resBadge = + ' ' + + t.resource_count + + " res"; + } var editDisabled = t.readonly ? " disabled" : ""; var deleteDisabled = t.readonly ? " disabled" : ""; html += @@ -742,6 +752,7 @@ function _renderGovSkills(items) { defBadge + originBadge + scanBadge + + resBadge + (t.description ? '
' + escapeHtml(t.description) + @@ -857,6 +868,9 @@ function showCreateTemplateModal() { document.getElementById("csk-allowed-tools").disabled = this.checked; }); document.getElementById("create-template-error").style.display = "none"; + // Clear resource list + _pendingResources = []; + _renderPendingResources(); document.getElementById("ctm-name").focus(); _ctmTrapHandler = _installTrap( "create-template-overlay", @@ -942,10 +956,36 @@ function submitCreateTemplate() { }); return r.json(); }) - .then(function () { - hideCreateTemplateModal(); - showToast("Skill created"); - loadGovSkills(); + .then(function (data) { + if (_pendingResources.length && data && data.template_id) { + var promises = _pendingResources.map(function (res) { + return authFetch( + "/v1/api/admin/skills/" + data.template_id + "/resources", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(res), + }, + ); + }); + Promise.all(promises) + .then(function () { + hideCreateTemplateModal(); + showToast( + "Skill created with " + _pendingResources.length + " resource(s)", + ); + loadGovSkills(); + }) + .catch(function () { + hideCreateTemplateModal(); + showToast("Skill created (some resources failed)"); + loadGovSkills(); + }); + } else { + hideCreateTemplateModal(); + showToast("Skill created"); + loadGovSkills(); + } }) .catch(function (e) { var el = document.getElementById("create-template-error"); @@ -1111,6 +1151,11 @@ function showEditTemplateModal(tmplId) { }); }; } + // --- Skill Resources --- + var resSection = document.getElementById("etm-resources-section"); + if (resSection) { + _loadSkillResources(tmplId, tmpl.readonly || false); + } _etmTrapHandler = _installTrap("edit-template-overlay", "edit-template-box"); } @@ -1123,6 +1168,245 @@ function hideEditTemplateModal() { _etmTriggerEl = null; } +// --------------------------------------------------------------------------- +// Skill Resources +// --------------------------------------------------------------------------- + +function _loadSkillResources(skillId, readonly) { + var container = document.getElementById("etm-resources-list"); + var addBtn = document.getElementById("etm-add-resource-btn"); + var addForm = document.getElementById("etm-add-resource-form"); + if (!container) return; + container.innerHTML = '
Loading...
'; + if (addBtn) addBtn.style.display = readonly ? "none" : ""; + if (addForm) addForm.style.display = "none"; + + authFetch("/v1/api/admin/skills/" + skillId + "/resources") + .then(function (r) { + if (!r.ok) throw new Error("Failed"); + return r.json(); + }) + .then(function (data) { + var resources = data.resources || []; + if (!resources.length) { + container.innerHTML = + '
No resource files
'; + return; + } + var html = ""; + for (var i = 0; i < resources.length; i++) { + var res = resources[i]; + var sizeStr = + res.size > 1024 + ? (res.size / 1024).toFixed(1) + " KB" + : res.size + " B"; + html += + '
' + + '' + + escapeHtml(res.path) + + "" + + '' + + sizeStr + + "" + + '' + + (readonly + ? "" + : '') + + "
"; + } + container.innerHTML = html; + if (!readonly) { + container.querySelectorAll("[data-del-res]").forEach(function (btn) { + btn.addEventListener("click", function () { + var path = this.getAttribute("data-del-res"); + showConfirmModal( + "Delete Resource", + 'Delete "' + path + '"?', + "Delete", + function () { + authFetch( + "/v1/api/admin/skills/" + + skillId + + "/resources/" + + encodeURIComponent(path), + { method: "DELETE" }, + ) + .then(function (r) { + if (!r.ok) throw new Error(); + return r.json(); + }) + .then(function () { + showToast("Resource deleted"); + _loadSkillResources(skillId, readonly); + loadGovSkills(); + var addBtn = document.getElementById( + "etm-add-resource-btn", + ); + if (addBtn) addBtn.focus(); + }) + .catch(function () { + showToast("Failed to delete resource"); + }); + }, + ); + }); + }); + } + }) + .catch(function () { + container.innerHTML = + '
Failed to load resources
'; + }); +} + +function _showAddResourceForm(skillId) { + var form = document.getElementById("etm-add-resource-form"); + if (!form) return; + form.style.display = ""; + document.getElementById("etm-res-path").value = ""; + document.getElementById("etm-res-content").value = ""; + document.getElementById("etm-res-content-type").value = "text/plain"; + document.getElementById("etm-res-submit").onclick = function () { + var path = (document.getElementById("etm-res-path").value || "").trim(); + var content = document.getElementById("etm-res-content").value || ""; + var contentType = document.getElementById("etm-res-content-type").value; + if (!path || !content) { + showToast("Path and content are required"); + return; + } + if ( + !path.startsWith("scripts/") && + !path.startsWith("references/") && + !path.startsWith("assets/") + ) { + showToast("Path must start with scripts/, references/, or assets/"); + return; + } + this.disabled = true; + this.textContent = "Uploading\u2026"; + authFetch("/v1/api/admin/skills/" + skillId + "/resources", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: path, + content: content, + content_type: contentType, + }), + }) + .then(function (r) { + if (!r.ok) + return r.json().then(function (d) { + throw new Error(d.error || "Failed"); + }); + return r.json(); + }) + .then(function () { + showToast("Resource added"); + form.style.display = "none"; + _loadSkillResources(skillId, false); + loadGovSkills(); + }) + .catch(function (e) { + showToast(e.message || "Failed to add resource"); + }) + .finally(function () { + var btn = document.getElementById("etm-res-submit"); + if (btn) { + btn.disabled = false; + btn.textContent = "Upload"; + } + }); + }; +} + +// --------------------------------------------------------------------------- +// Pending resources (create modal) +// --------------------------------------------------------------------------- + +function _renderPendingResources() { + var container = document.getElementById("ctm-resources-list"); + if (!container) return; + if (!_pendingResources.length) { + container.innerHTML = + '
No resource files yet
'; + return; + } + var html = ""; + for (var i = 0; i < _pendingResources.length; i++) { + var r = _pendingResources[i]; + var sizeStr = + r.content.length > 1024 + ? (r.content.length / 1024).toFixed(1) + " KB" + : r.content.length + " B"; + html += + '
' + + '' + + escapeHtml(r.path) + + "" + + '' + + sizeStr + + "" + + '' + + '' + + "
"; + } + container.innerHTML = html; + container.querySelectorAll("[data-remove-res]").forEach(function (btn) { + btn.addEventListener("click", function () { + var idx = parseInt(this.getAttribute("data-remove-res"), 10); + _pendingResources.splice(idx, 1); + _renderPendingResources(); + }); + }); +} + +function _addPendingResource() { + var path = (document.getElementById("ctm-res-path").value || "").trim(); + var content = document.getElementById("ctm-res-content").value || ""; + var contentType = document.getElementById("ctm-res-content-type").value; + if (!path || !content) { + showToast("Path and content are required"); + return; + } + if ( + !path.startsWith("scripts/") && + !path.startsWith("references/") && + !path.startsWith("assets/") + ) { + showToast("Path must start with scripts/, references/, or assets/"); + return; + } + if ( + _pendingResources.some(function (r) { + return r.path === path; + }) + ) { + showToast("Resource path already added"); + return; + } + if (_pendingResources.length >= 10) { + showToast("Maximum 10 resources per skill"); + return; + } + _pendingResources.push({ + path: path, + content: content, + content_type: contentType, + }); + document.getElementById("ctm-res-path").value = ""; + document.getElementById("ctm-res-content").value = ""; + _renderPendingResources(); + document.getElementById("ctm-res-path").focus(); +} + function submitEditTemplate() { var id = document.getElementById("etm-id").value; var content = document.getElementById("etm-content").value; @@ -1762,6 +2046,10 @@ function switchSkillView(view) { function searchSkillDiscover() { var q = (document.getElementById("skill-discover-q").value || "").trim(); + if (!q) { + showToast("Enter a search query"); + return; + } _skillDiscoverResults = []; _skillDiscoverQuery = q; diff --git a/turnstone/console/static/index.html b/turnstone/console/static/index.html index b9727a1c..9d5b6da8 100644 --- a/turnstone/console/static/index.html +++ b/turnstone/console/static/index.html @@ -95,7 +95,11 @@ + +
+ +
@@ -106,7 +110,6 @@
-
@@ -406,7 +409,7 @@ MCP
- +
@@ -898,6 +901,19 @@ window.TURNSTONE_KB_SHORTCUTS = [ +
+ Resources optional bundled files (scripts, references, assets) +
+
+ + + + + + + +
+
+
+ Resources bundled files for this skill +
+ + +