mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat: skill resources — API, admin UI, runtime injection, and SDK
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 <skill-resources> 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)
This commit is contained in:
committed by
Patrick Buckley
parent
7bc17cc072
commit
28a6b0dd33
@@ -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<SkillResourceInfo[]> {
|
||||
const resp = await this.request<ListSkillResourcesResponse>(
|
||||
"GET",
|
||||
`/v1/api/admin/skills/${skillId}/resources`,
|
||||
);
|
||||
return resp.resources;
|
||||
}
|
||||
|
||||
async createSkillResource(
|
||||
skillId: string,
|
||||
body: CreateSkillResourceRequest,
|
||||
): Promise<SkillResourceInfo> {
|
||||
return this.request("POST", `/v1/api/admin/skills/${skillId}/resources`, {
|
||||
json: body,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSkillResource(skillId: string, path: string): Promise<void> {
|
||||
await this.request(
|
||||
"DELETE",
|
||||
`/v1/api/admin/skills/${skillId}/resources/${encodeURIComponent(path)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// -- Governance: Usage & Audit ----------------------------------------------
|
||||
|
||||
async getUsage(opts: UsageQueryOptions): Promise<UsageResponse> {
|
||||
|
||||
@@ -131,6 +131,9 @@ export type {
|
||||
CreateSkillRequest,
|
||||
UpdateSkillRequest,
|
||||
ListSkillsResponse,
|
||||
SkillResourceInfo,
|
||||
ListSkillResourcesResponse,
|
||||
CreateSkillResourceRequest,
|
||||
UsageBreakdownItem,
|
||||
UsageResponse,
|
||||
UsageQueryOptions,
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
+214
-3
@@ -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),
|
||||
|
||||
@@ -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) +
|
||||
"</span>";
|
||||
}
|
||||
var resBadge = "";
|
||||
if (t.resource_count > 0) {
|
||||
resBadge =
|
||||
' <span class="scope-badge" title="' +
|
||||
t.resource_count +
|
||||
' bundled resource(s)">' +
|
||||
t.resource_count +
|
||||
" res</span>";
|
||||
}
|
||||
var editDisabled = t.readonly ? " disabled" : "";
|
||||
var deleteDisabled = t.readonly ? " disabled" : "";
|
||||
html +=
|
||||
@@ -742,6 +752,7 @@ function _renderGovSkills(items) {
|
||||
defBadge +
|
||||
originBadge +
|
||||
scanBadge +
|
||||
resBadge +
|
||||
(t.description
|
||||
? '<br><span class="admin-col-subtitle">' +
|
||||
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 = '<div class="dashboard-empty">Loading...</div>';
|
||||
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 =
|
||||
'<div class="dashboard-empty">No resource files</div>';
|
||||
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 +=
|
||||
'<div role="listitem" style="display:flex;align-items:center;padding:4px 0;gap:8px">' +
|
||||
'<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><code>' +
|
||||
escapeHtml(res.path) +
|
||||
"</code></span>" +
|
||||
'<span style="width:80px;text-align:right;opacity:0.6">' +
|
||||
sizeStr +
|
||||
"</span>" +
|
||||
'<span style="width:60px;text-align:right">' +
|
||||
(readonly
|
||||
? ""
|
||||
: '<button class="admin-btn-danger" data-del-res="' +
|
||||
escapeHtml(res.path) +
|
||||
'" style="font-size:0.85em" aria-label="Delete resource ' +
|
||||
escapeHtml(res.path) +
|
||||
'">delete</button>') +
|
||||
"</span></div>";
|
||||
}
|
||||
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 =
|
||||
'<div class="dashboard-empty">Failed to load resources</div>';
|
||||
});
|
||||
}
|
||||
|
||||
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 =
|
||||
'<div class="dashboard-empty">No resource files yet</div>';
|
||||
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 +=
|
||||
'<div role="listitem" style="display:flex;align-items:center;padding:4px 0;gap:8px">' +
|
||||
'<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><code>' +
|
||||
escapeHtml(r.path) +
|
||||
"</code></span>" +
|
||||
'<span style="width:80px;text-align:right;opacity:0.6">' +
|
||||
sizeStr +
|
||||
"</span>" +
|
||||
'<span style="width:60px;text-align:right">' +
|
||||
'<button class="admin-btn-danger" data-remove-res="' +
|
||||
i +
|
||||
'" style="font-size:0.85em" aria-label="Remove resource ' +
|
||||
escapeHtml(r.path) +
|
||||
'">remove</button>' +
|
||||
"</span></div>";
|
||||
}
|
||||
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;
|
||||
|
||||
|
||||
@@ -95,7 +95,11 @@
|
||||
<div class="admin-sidebar-group-label" aria-hidden="true">Governance</div>
|
||||
<button id="tab-roles" class="admin-nav" data-tab="roles" role="tab" aria-selected="false" aria-controls="admin-roles" tabindex="-1" onclick="switchAdminTab('roles')">Roles</button>
|
||||
<button id="tab-policies" class="admin-nav" data-tab="policies" role="tab" aria-selected="false" aria-controls="admin-policies" tabindex="-1" onclick="switchAdminTab('policies')">Policies</button>
|
||||
</div>
|
||||
<div class="admin-sidebar-group" data-group="extensions" role="group" aria-label="Extensions">
|
||||
<div class="admin-sidebar-group-label" aria-hidden="true">Extensions</div>
|
||||
<button id="tab-skills" class="admin-nav" data-tab="skills" role="tab" aria-selected="false" aria-controls="admin-skills" tabindex="-1" onclick="switchAdminTab('skills')">Skills</button>
|
||||
<button id="tab-mcp" class="admin-nav" data-tab="mcp" role="tab" aria-selected="false" aria-controls="admin-mcp" tabindex="-1" onclick="switchAdminTab('mcp')">MCP Servers</button>
|
||||
</div>
|
||||
<div class="admin-sidebar-group" data-group="observe" role="group" aria-label="Observe">
|
||||
<div class="admin-sidebar-group-label" aria-hidden="true">Observe</div>
|
||||
@@ -106,7 +110,6 @@
|
||||
<div class="admin-sidebar-group" data-group="system" role="group" aria-label="System">
|
||||
<div class="admin-sidebar-group-label" aria-hidden="true">System</div>
|
||||
<button id="tab-settings" class="admin-nav" data-tab="settings" role="tab" aria-selected="false" aria-controls="admin-settings" tabindex="-1" onclick="switchAdminTab('settings')">Settings</button>
|
||||
<button id="tab-mcp" class="admin-nav" data-tab="mcp" role="tab" aria-selected="false" aria-controls="admin-mcp" tabindex="-1" onclick="switchAdminTab('mcp')">MCP Servers</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div id="admin-sidebar-backdrop" class="admin-sidebar-backdrop" aria-hidden="true"></div>
|
||||
@@ -406,7 +409,7 @@
|
||||
<span class="section-header">MCP</span>
|
||||
<div class="mcp-view-toggle" role="tablist" aria-label="MCP view">
|
||||
<button class="mcp-view-btn active" data-mcp-view="servers" role="tab" aria-selected="true" aria-controls="mcp-view-servers" tabindex="0" onclick="switchMcpView('servers')">Servers</button>
|
||||
<button class="mcp-view-btn" data-mcp-view="registry" role="tab" aria-selected="false" aria-controls="mcp-view-registry" tabindex="-1" onclick="switchMcpView('registry')">Registry</button>
|
||||
<button class="mcp-view-btn" data-mcp-view="registry" role="tab" aria-selected="false" aria-controls="mcp-view-registry" tabindex="-1" onclick="switchMcpView('registry')">Discover</button>
|
||||
</div>
|
||||
<span id="mcp-servers-toolbar">
|
||||
<button id="mcp-sync-btn" class="admin-action-btn admin-action-btn-ghost" onclick="reloadMcpNodes()" title="Push MCP server config to all cluster nodes and reconnect">Sync to Nodes</button>
|
||||
@@ -898,6 +901,19 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
<input id="csk-allowed-tools" type="text" placeholder="bash, read_file, write_file">
|
||||
<label class="admin-checkbox"><input id="csk-enabled" type="checkbox" checked> Enabled</label>
|
||||
</details>
|
||||
<details class="admin-details">
|
||||
<summary>Resources <span class="label-hint">optional bundled files (scripts, references, assets)</span></summary>
|
||||
<div id="ctm-resources-list" role="list" aria-live="polite" aria-label="Pending resources"></div>
|
||||
<div style="margin-top:8px;display:flex;flex-direction:column;gap:6px">
|
||||
<label for="ctm-res-path">Path</label>
|
||||
<input id="ctm-res-path" type="text" placeholder="scripts/setup.sh or references/guide.md">
|
||||
<label for="ctm-res-content-type">Content Type</label>
|
||||
<input id="ctm-res-content-type" type="text" value="text/plain">
|
||||
<label for="ctm-res-content">Content</label>
|
||||
<textarea id="ctm-res-content" rows="4" placeholder="Resource file content"></textarea>
|
||||
<button type="button" class="admin-btn-action" onclick="_addPendingResource()">Add Resource</button>
|
||||
</div>
|
||||
</details>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideCreateTemplateModal()">Cancel</button>
|
||||
<button id="ctm-submit" class="modal-submit" onclick="submitCreateTemplate()">Create</button>
|
||||
@@ -966,6 +982,22 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
<div id="etm-scan-report" aria-labelledby="etm-scan-heading"></div>
|
||||
<button type="button" id="etm-rescan-btn" class="admin-btn-action" style="margin-top:8px">Re-scan</button>
|
||||
</div>
|
||||
<details id="etm-resources-section" class="admin-details">
|
||||
<summary>Resources <span class="label-hint">bundled files for this skill</span></summary>
|
||||
<div id="etm-resources-list" role="list" aria-live="polite" aria-label="Skill resources"></div>
|
||||
<button type="button" id="etm-add-resource-btn" class="admin-btn-action" style="margin-top:8px" onclick="_showAddResourceForm(document.getElementById('etm-id').value)">Add Resource</button>
|
||||
<div id="etm-add-resource-form" style="display:none">
|
||||
<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">
|
||||
<label for="etm-res-path">Path</label>
|
||||
<input id="etm-res-path" type="text" placeholder="scripts/setup.sh or references/guide.md">
|
||||
<label for="etm-res-content-type">Content Type</label>
|
||||
<input id="etm-res-content-type" type="text" value="text/plain">
|
||||
<label for="etm-res-content">Content</label>
|
||||
<textarea id="etm-res-content" rows="4" placeholder="Resource file content"></textarea>
|
||||
<button type="button" id="etm-res-submit" class="admin-btn-action">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideEditTemplateModal()">Cancel</button>
|
||||
<button id="etm-submit" class="modal-submit" onclick="submitEditTemplate()">Save</button>
|
||||
|
||||
@@ -347,6 +347,7 @@ class ChatSession:
|
||||
# Skill: explicit name overrides is_default skills
|
||||
self._skill_name: str | None = skill
|
||||
self._skill_content: str | None = None
|
||||
self._skill_resources: dict[str, str] = {}
|
||||
self._load_skills()
|
||||
self._init_system_messages()
|
||||
self._save_config()
|
||||
@@ -405,6 +406,9 @@ class ChatSession:
|
||||
if skill_data:
|
||||
self._skill_content = _render_template(skill_data["content"], context)
|
||||
self._check_skill_budget(skill_data)
|
||||
self._skill_resources = self._load_skill_resources(
|
||||
skill_data.get("template_id", "")
|
||||
)
|
||||
if skill_data.get("scan_status") in ("high", "critical"):
|
||||
scan_tier = skill_data["scan_status"]
|
||||
log.warning(
|
||||
@@ -419,6 +423,7 @@ class ChatSession:
|
||||
else:
|
||||
log.warning("skill.not_found", name=self._skill_name)
|
||||
self._skill_content = None
|
||||
self._skill_resources = {}
|
||||
else:
|
||||
defaults = list_default_skills()
|
||||
if defaults:
|
||||
@@ -426,6 +431,7 @@ class ChatSession:
|
||||
self._skill_content = "\n\n".join(parts)
|
||||
else:
|
||||
self._skill_content = None
|
||||
self._skill_resources = {}
|
||||
|
||||
def set_skill(self, name: str | None) -> None:
|
||||
"""Set or clear the active skill."""
|
||||
@@ -444,6 +450,18 @@ class ChatSession:
|
||||
context_window=self.context_window,
|
||||
)
|
||||
|
||||
def _load_skill_resources(self, skill_id: str) -> dict[str, str]:
|
||||
"""Load bundled resources for a skill and return {path: content}."""
|
||||
if not skill_id:
|
||||
return {}
|
||||
try:
|
||||
storage = get_storage()
|
||||
rows = storage.list_skill_resources(skill_id)
|
||||
return {r["path"]: r.get("content", "") for r in rows}
|
||||
except Exception:
|
||||
log.warning("skill_resources.load_failed", skill_id=skill_id, exc_info=True)
|
||||
return {}
|
||||
|
||||
# -- MCP tool refresh ----------------------------------------------------
|
||||
|
||||
def _on_mcp_tools_changed(self) -> None:
|
||||
@@ -820,6 +838,24 @@ class ChatSession:
|
||||
tpl = tpl[:_MAX_SKILL_CONTENT]
|
||||
dev_parts.append("")
|
||||
dev_parts.append(tpl)
|
||||
if self._skill_resources:
|
||||
lines = ["<skill-resources>"]
|
||||
total_size = 0
|
||||
for rpath, rcontent in sorted(self._skill_resources.items()):
|
||||
size_kb = f"{len(rcontent) / 1024:.1f}KB"
|
||||
total_size += len(rcontent)
|
||||
lines.append(f"- {rpath} ({size_kb})")
|
||||
if total_size <= 8192:
|
||||
for rpath, rcontent in sorted(self._skill_resources.items()):
|
||||
lines.append(f"\n--- {rpath} ---")
|
||||
lines.append(rcontent)
|
||||
else:
|
||||
lines.append(
|
||||
"Resource content omitted (total exceeds 8KB). "
|
||||
"Resource files are listed above by path and size."
|
||||
)
|
||||
lines.append("</skill-resources>")
|
||||
dev_parts.append("\n".join(lines))
|
||||
if self.instructions:
|
||||
dev_parts.append("")
|
||||
dev_parts.append(self.instructions)
|
||||
|
||||
@@ -1773,6 +1773,33 @@ class PostgreSQLBackend:
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
def delete_skill_resource_by_path(self, skill_id: str, path: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(skill_resources).where(
|
||||
sa.and_(
|
||||
skill_resources.c.skill_id == skill_id,
|
||||
skill_resources.c.path == path,
|
||||
)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def count_skill_resources_bulk(self, skill_ids: list[str]) -> dict[str, int]:
|
||||
if not skill_ids:
|
||||
return {}
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(
|
||||
skill_resources.c.skill_id,
|
||||
sa.func.count().label("cnt"),
|
||||
)
|
||||
.where(skill_resources.c.skill_id.in_(skill_ids))
|
||||
.group_by(skill_resources.c.skill_id)
|
||||
).fetchall()
|
||||
return {r[0]: r[1] for r in rows}
|
||||
|
||||
# -- Skill versions --------------------------------------------------------
|
||||
|
||||
def create_skill_version(
|
||||
|
||||
@@ -658,6 +658,14 @@ class StorageBackend(Protocol):
|
||||
"""Delete all resource files for a skill. Returns count deleted."""
|
||||
...
|
||||
|
||||
def delete_skill_resource_by_path(self, skill_id: str, path: str) -> bool:
|
||||
"""Delete a single resource file by skill_id and path. Returns True if found."""
|
||||
...
|
||||
|
||||
def count_skill_resources_bulk(self, skill_ids: list[str]) -> dict[str, int]:
|
||||
"""Count resources per skill in a single query. Returns {skill_id: count}."""
|
||||
...
|
||||
|
||||
# -- Skill versions --------------------------------------------------------
|
||||
|
||||
def create_skill_version(
|
||||
|
||||
@@ -1797,6 +1797,33 @@ class SQLiteBackend:
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
def delete_skill_resource_by_path(self, skill_id: str, path: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(skill_resources).where(
|
||||
sa.and_(
|
||||
skill_resources.c.skill_id == skill_id,
|
||||
skill_resources.c.path == path,
|
||||
)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def count_skill_resources_bulk(self, skill_ids: list[str]) -> dict[str, int]:
|
||||
if not skill_ids:
|
||||
return {}
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(
|
||||
skill_resources.c.skill_id,
|
||||
sa.func.count().label("cnt"),
|
||||
)
|
||||
.where(skill_resources.c.skill_id.in_(skill_ids))
|
||||
.group_by(skill_resources.c.skill_id)
|
||||
).fetchall()
|
||||
return {r[0]: r[1] for r in rows}
|
||||
|
||||
# -- Skill versions --------------------------------------------------------
|
||||
|
||||
def create_skill_version(
|
||||
|
||||
@@ -454,6 +454,33 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
"DELETE", f"/v1/api/admin/skills/{skill_id}", response_model=StatusResponse
|
||||
)
|
||||
|
||||
async def list_skill_resources(self, skill_id: str) -> list[dict[str, Any]]:
|
||||
"""List resource files for a skill."""
|
||||
resp = await self._request("GET", f"/v1/api/admin/skills/{skill_id}/resources")
|
||||
resources: list[dict[str, Any]] = resp.get("resources", [])
|
||||
return resources
|
||||
|
||||
async def create_skill_resource(
|
||||
self,
|
||||
skill_id: str,
|
||||
path: str,
|
||||
content: str,
|
||||
content_type: str = "text/plain",
|
||||
) -> dict[str, Any]:
|
||||
"""Upload a resource file to a skill."""
|
||||
body: dict[str, Any] = {"path": path, "content": content, "content_type": content_type}
|
||||
return await self._request(
|
||||
"POST", f"/v1/api/admin/skills/{skill_id}/resources", json_body=body
|
||||
)
|
||||
|
||||
async def delete_skill_resource(self, skill_id: str, path: str) -> StatusResponse:
|
||||
"""Delete a skill resource by path."""
|
||||
return await self._request(
|
||||
"DELETE",
|
||||
f"/v1/api/admin/skills/{skill_id}/resources/{path}",
|
||||
response_model=StatusResponse,
|
||||
)
|
||||
|
||||
# -- governance: usage & audit -------------------------------------------
|
||||
|
||||
async def get_usage(
|
||||
@@ -1040,6 +1067,23 @@ class TurnstoneConsole:
|
||||
def delete_skill(self, skill_id: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.delete_skill(skill_id))
|
||||
|
||||
def list_skill_resources(self, skill_id: str) -> list[dict[str, Any]]:
|
||||
return self._runner.run(self._async.list_skill_resources(skill_id))
|
||||
|
||||
def create_skill_resource(
|
||||
self,
|
||||
skill_id: str,
|
||||
path: str,
|
||||
content: str,
|
||||
content_type: str = "text/plain",
|
||||
) -> dict[str, Any]:
|
||||
return self._runner.run(
|
||||
self._async.create_skill_resource(skill_id, path, content, content_type)
|
||||
)
|
||||
|
||||
def delete_skill_resource(self, skill_id: str, path: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.delete_skill_resource(skill_id, path))
|
||||
|
||||
# -- governance: usage & audit -------------------------------------------
|
||||
|
||||
def get_usage(
|
||||
|
||||
Reference in New Issue
Block a user