Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions frontend/src/create/LocalPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Local skill picker: user uploads a folder (via <input webkitdirectory>) or a
// .zip file from their computer. Each skill directory must contain a SKILL.md
// with name/description frontmatter. Files are materialized client-side and
// .zip file from their computer. Each skill directory must contain a SKILL.md.
// Files are materialized client-side and
// embedded directly in SelectedSkill.localFiles so they survive YAML
// round-trip and project generation without any network round-trip.

Expand Down Expand Up @@ -85,7 +85,7 @@ export function LocalPicker({
const [dragging, setDragging] = useState(false);
const dragDepthRef = useRef(0);

// Match by folder name (frontmatter `name`), the de-dup key for local uploads.
// Match by generated folder name, the de-dup key for local uploads.
const isSelectedByFolder = (folder: string) =>
selected.some((s) => s.source === "local" && s.folder === folder);

Expand Down Expand Up @@ -125,8 +125,8 @@ export function LocalPicker({

const showResult = (res: LocalReadResult) => {
// Merge new hits into the displayed list (append, don't replace) so
// repeated uploads accumulate. De-dupe by folder name (frontmatter
// `name`) across both the displayed list and already-selected skills.
// repeated uploads accumulate. De-dupe by generated folder name across
// both the displayed list and already-selected skills.
const existing = new Set([
...foundHitsRef.current.map((h) => h.folder || h.name),
...selectedRef.current
Expand Down Expand Up @@ -244,7 +244,7 @@ export function LocalPicker({
</div>

<p className="cw-local-hint">
每个技能需包含 SKILL.md(含 name / description frontmatter)。支持包含多个技能的目录。
每个技能需包含 SKILL.md。支持包含多个技能的目录。
</p>

{busy && <p className="cw-empty-line">正在读取文件…</p>}
Expand Down
95 changes: 29 additions & 66 deletions frontend/src/create/skills/local.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
// Local skill upload: read a browser folder (via <input webkitdirectory>) or a
// .zip file into SkillHit entries. Each skill directory must contain a
// SKILL.md (case-insensitive) with valid frontmatter. Validation mirrors what
// ADK's skill loader requires (so skills actually load at runtime):
// - SKILL.md starts with a closed `--- ... ---` frontmatter block
// - `name`: required, <=64 chars, matches [a-z0-9-]+ (kebab-case)
// - `description`: required, <=1024 chars, no XML tags
// - quoted name/description values are accepted (quotes stripped) — real YAML
// parsers handle them fine, and the platform's line-based parser only
// applies to pushed skills, not locally-uploaded ones.
// SKILL.md (case-insensitive). We keep local upload validation intentionally
// light: identify the skill folder, keep paths inside that folder, and let ADK
// report any deeper SKILL.md/frontmatter issues when the agent actually loads.
//
// Resulting ProjectFiles are rooted at `skills/<frontmatter-name>/...` so they
// merge cleanly with the existing Skill Hub download layout. Zip-slip paths
Expand All @@ -30,34 +25,20 @@ interface RawEntry {

const SKILL_MD_RE = /(^|\/)skill\.md$/i;

/** Validation error: carries a human-readable message. */
class SkillValidationError extends Error {}

/** Parse a YAML "key: value" line the same way the agentkit validator does
* (simple line-based, no full YAML parser, rejects quoted name/description).
* Returns { name, description } on success, throws on any violation. */
function parseAndValidateSkillMd(text: string, where: string): {
function readSkillMdMetadata(text: string): {
name: string;
description: string;
} {
const lines = (text ?? "").replace(/\r\n?/g, "\n").split("\n");
if (!lines.length || lines[0].trim() !== "---") {
throw new SkillValidationError(
`${where} 的 SKILL.md 必须以 YAML frontmatter (--- ... ---) 开头`,
);
}
if (!lines.length || lines[0].trim() !== "---") return { name: "", description: "" };
let endIdx = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === "---") {
endIdx = i;
break;
}
}
if (endIdx < 0) {
throw new SkillValidationError(
`${where} 的 SKILL.md frontmatter 未闭合(缺少结束 ---)`,
);
}
if (endIdx < 0) return { name: "", description: "" };
const meta: Record<string, string> = {};
for (let i = 1; i < endIdx; i++) {
const s = lines[i].trim();
Expand All @@ -72,43 +53,33 @@ function parseAndValidateSkillMd(text: string, where: string): {
meta[key] = val;
}

const name = (meta.name || "").trim();
const description = (meta.description || "").trim();
validateName(name, where);
validateDescription(description, where);
return { name, description };
return {
name: (meta.name || "").trim(),
description: (meta.description || "").trim(),
};
}

function isQuoted(v: string): boolean {
return v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")));
}

function validateName(name: string, where: string) {
if (!name) {
throw new SkillValidationError(`${where} 的 SKILL.md 缺少必填的 name frontmatter`);
}
if (name.length > 64) {
throw new SkillValidationError(`${where} 的 name 长度超过 64 个字符`);
}
if (!/^[a-z0-9-]+$/.test(name)) {
throw new SkillValidationError(
`${where} 的 name 必须匹配 [a-z0-9-]+(小写字母、数字、短横线)`,
);
}
function safeSkillFolder(...candidates: string[]): string {
for (const candidate of candidates) {
const folder = candidate
.trim()
.replace(/\\/g, "/")
.split("/")
.filter(Boolean)
.pop()
?.replace(/[^A-Za-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
if (folder) return folder.slice(0, 64);
}
return "local-skill";
}

function validateDescription(desc: string, where: string) {
if (!desc) {
throw new SkillValidationError(
`${where} 的 SKILL.md 缺少必填的 description frontmatter`,
);
}
if (desc.length > 1024) {
throw new SkillValidationError(`${where} 的 description 长度超过 1024 个字符`);
}
if (/<[^>]+>/.test(desc)) {
throw new SkillValidationError(`${where} 的 description 不能包含 XML 标签`);
}
function displaySkillName(folder: string, frontmatterName: string): string {
return frontmatterName.trim() || folder;
}

/** Normalize path separators and strip a single common wrapper folder if the
Expand Down Expand Up @@ -173,16 +144,8 @@ function materializeHit(
if (!md) {
return { hit: null, error: `${where} 缺少 SKILL.md` };
}
let fm: { name: string; description: string };
try {
fm = parseAndValidateSkillMd(md.text, where);
} catch (e) {
return {
hit: null,
error: e instanceof Error ? e.message : String(e),
};
}
const folder = fm.name;
const fm = readSkillMdMetadata(md.text);
const folder = safeSkillFolder(fm.name, folderKey, sourceLabel.replace(/\.[^.]+$/, ""));
const projectFiles: ProjectFile[] = [];
for (const e of files) {
// Zip-slip guard: reject paths containing '..' or that would escape
Expand All @@ -201,8 +164,8 @@ function materializeHit(
hit: {
source: "local",
id: `local:${folder}:${files.length}`,
name: fm.name,
description: fm.description,
name: displaySkillName(folder, fm.name),
description: fm.description || "本地 Skill",
folder,
localFiles: projectFiles,
},
Expand Down
8 changes: 8 additions & 0 deletions frontend/tests/markdownPromptEditor.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ const localPickerSource = readFileSync(
new URL("../src/create/LocalPicker.tsx", import.meta.url),
"utf8",
);
const localSkillSource = readFileSync(
new URL("../src/create/skills/local.ts", import.meta.url),
"utf8",
);
const configYamlSource = readFileSync(
new URL("../src/create/configYaml.ts", import.meta.url),
"utf8",
Expand Down Expand Up @@ -465,6 +469,10 @@ test("local Skill folders and ZIP archives support drag and drop", () => {
);
assert.match(localPickerSource, /readZipSkills\(dropped\[0\]\.file\)/);
assert.match(localPickerSource, /readFolderSkills\(dropped\.map/);
assert.match(localSkillSource, /function readSkillMdMetadata/);
assert.match(localSkillSource, /function safeSkillFolder/);
assert.doesNotMatch(localSkillSource, /function validateName/);
assert.doesNotMatch(localSkillSource, /function validateDescription/);
assert.match(
createStyles,
/\.cw-local-dropzone\.is-dragging\s*\{[\s\S]*?border-color:/,
Expand Down
49 changes: 49 additions & 0 deletions tests/cli/test_frontend_skill_spaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import asyncio
import json
import zipfile

from pathlib import Path
from types import SimpleNamespace
Expand Down Expand Up @@ -448,6 +449,54 @@ def get_skill_version(self, request: Any) -> SimpleNamespace:
assert request.skill_version == "1.2.0"


def test_get_skill_detail_falls_back_to_skillspace_package(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
app = _create_frontend_app(monkeypatch, tmp_path)

class _FakeSkillsClient:
def __init__(self, **kwargs: Any) -> None:
del kwargs

def get_skill_version(self, request: Any) -> SimpleNamespace:
del request
return SimpleNamespace(
name="ticket-classifier",
description="识别工单类型",
version="1.2.0",
skill_md="",
bucket_name="skills-bucket",
tos_path="skills/ticket-classifier.zip",
)

def _download_skill(skill: Any, zip_path: Path) -> bool:
assert skill.bucket_name == "skills-bucket"
assert skill.path == "skills/ticket-classifier.zip"
with zipfile.ZipFile(zip_path, "w") as archive:
archive.writestr(
"ticket-classifier/SKILL.md",
"---\nname: ticket-classifier\ndescription: Tickets.\n---\nBody.\n",
)
return True

monkeypatch.setattr(
"agentkit.sdk.skills.client.AgentkitSkillsClient", _FakeSkillsClient
)
monkeypatch.setattr(
"veadk.skills.materializer._download_legacy_skill_space_skill",
_download_skill,
)

with TestClient(app) as client:
response = client.get(
"/web/skill-spaces/space-1/skills/skill-1",
params={"region": "cn-shanghai", "version": "1.2.0"},
)

assert response.status_code == 200
assert response.json()["skillMd"].endswith("Body.\n")


def test_skill_space_routes_keep_missing_credentials_status(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
Expand Down
29 changes: 29 additions & 0 deletions tests/cli/test_generated_agent_backend_codegen.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,35 @@ async def test_local_skill_materialization_accepts_safe_skill() -> None:
]


@pytest.mark.asyncio
async def test_local_skill_materialization_keeps_validation_minimal() -> None:
skill_md = "---\nname: Display Skill\n---\n\n# Local\n"
draft = AgentDraft(
name="demo",
instruction="You are helpful.",
selectedSkills=[
SelectedSkill(
source="local",
folder="local-folder",
name="Display Skill",
localFiles=[
GeneratedFile(
path="skills/local-folder/SKILL.md",
content=skill_md,
)
],
)
],
)
project = GeneratedProject(name="demo", files=[])

await materialize_selected_skills(draft, project)

assert project.files == [
GeneratedFile(path="skills/local-folder/SKILL.md", content=skill_md)
]


@pytest.mark.asyncio
async def test_local_skill_materialization_rejects_path_escape() -> None:
skill_md = "---\nname: local-skill\ndescription: Local skill.\n---\n"
Expand Down
Loading
Loading