From 7cc5c30e71628898d6f08d3ef689316a49bfe4c3 Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Tue, 4 Aug 2026 20:20:07 +0800 Subject: [PATCH] feat(skills): agent settings Skills tab with uninstall and archive/chat import (#179) Co-authored-by: Claude Fable 5 --- packages/server/package.json | 1 + packages/server/src/api/types.ts | 13 + packages/server/src/http/routes/skills.ts | 240 +++++++++- packages/server/test/skills.test.ts | 227 +++++++++- packages/skills/src/index.ts | 4 +- packages/web/src/api/endpoints.ts | 18 + .../features/agents/agent-settings-page.tsx | 11 +- .../features/agents/skill-import-source.ts | 51 +++ .../web/src/features/agents/skills-tab.tsx | 428 ++++++++++++++++++ packages/web/src/lib/strings-en.ts | 46 ++ packages/web/src/lib/strings.ts | 42 ++ packages/web/test/skill-import-source.test.ts | 63 +++ pnpm-lock.yaml | 9 + 13 files changed, 1141 insertions(+), 12 deletions(-) create mode 100644 packages/web/src/features/agents/skill-import-source.ts create mode 100644 packages/web/src/features/agents/skills-tab.tsx create mode 100644 packages/web/test/skill-import-source.test.ts diff --git a/packages/server/package.json b/packages/server/package.json index e4c04eb..7829354 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -40,6 +40,7 @@ "@prismshadow/penguin-core": "workspace:*", "@prismshadow/penguin-skills": "workspace:*", "dotenv": "^17.0.0", + "fflate": "^0.8.3", "hono": "^4.8.0", "smol-toml": "^1.3.0", "tar": "^7.5.21", diff --git a/packages/server/src/api/types.ts b/packages/server/src/api/types.ts index fa588cd..0aeda39 100644 --- a/packages/server/src/api/types.ts +++ b/packages/server/src/api/types.ts @@ -1397,6 +1397,19 @@ export interface SkillInstallRequest { names: string[]; } +/** + * POST /api/projects/:p/agents/:a/skills/archive: install one Skill from an uploaded zip. + * Layout: SKILL.md at the zip root, or exactly one top-level directory containing SKILL.md + * (the directory name is then the Skill name). 201 returns the refreshed installed list + * (AgentSkillsResponse); an already-installed name without `overwrite` is 409 `skill_exists`. + */ +export interface SkillArchiveInstallRequest { + /** Base64-encoded zip archive (decoded size capped at 14MB, same as the Agent snapshot import). */ + dataBase64: string; + /** Replace an already-installed Skill of the same name (deletes its directory first). */ + overwrite?: boolean; +} + // --------------------------------------------------------------------------- // Version and self-update // --------------------------------------------------------------------------- diff --git a/packages/server/src/http/routes/skills.ts b/packages/server/src/http/routes/skills.ts index e8a9759..23ef771 100644 --- a/packages/server/src/http/routes/skills.ts +++ b/packages/server/src/http/routes/skills.ts @@ -2,13 +2,19 @@ * Skill library & Agent-installed-Skills routes: * GET /api/skills # library groups & metadata (any logged-in user) * GET|POST /api/projects/:p/agents/:a/skills # installed list / install from library (any member) + * POST /api/projects/:p/agents/:a/skills/archive # install one skill from an uploaded zip (any member) + * GET /api/projects/:p/agents/:a/skills/:name/archive # export one installed skill as a zip (any member) * DELETE /api/projects/:p/agents/:a/skills/:name # uninstall (any member) * Installing writes the library's SKILL.md verbatim to agent_state/skills//; - * reinstalling overwrites with the library content (i.e. an update). The scope is small - * enough to skip a service layer — routes call core's disk-writing functions directly. + * reinstalling overwrites with the library content (i.e. an update). The archive routes + * are symmetric: POST writes every zip file under skills// (replace semantics with + * `overwrite`), GET packs the whole directory back under a single top-level / so + * the download round-trips through the POST unchanged. The scope is small enough to skip + * a service layer — routes call core's disk-writing functions directly. */ import fs from "node:fs/promises"; import path from "node:path"; +import { unzipSync, strFromU8, zipSync } from "fflate"; import { Hono } from "hono"; import { installSkill, @@ -16,7 +22,12 @@ import { removeSkill, skillsDir, } from "@prismshadow/penguin-core"; -import { librarySkill, loadSkillGroups } from "@prismshadow/penguin-skills"; +import { + librarySkill, + loadSkillGroups, + parseSkillFrontmatter, + SKILL_NAME_PATTERN, +} from "@prismshadow/penguin-skills"; import type { LibrarySkill, SkillMetadata } from "@prismshadow/penguin-skills"; import type { AgentSkillsResponse, @@ -26,7 +37,14 @@ import type { import type { AppEnv } from "../../auth/middleware.js"; import type { AppDeps } from "../../app.js"; import { HttpError } from "../errors.js"; -import { badRequest, readJson, requireValidId } from "../validate.js"; +import { badRequest, readJson, requireString, requireValidId } from "../validate.js"; + +/** Decoded zip cap: aligned with the Agent snapshot import (stays within the 20MB body limit after base64). */ +const MAX_ARCHIVE_BYTES = 14 * 1024 * 1024; +/** Uncompressed limits (guard against zip bombs): entry count / per-file / total. */ +const MAX_ARCHIVE_FILES = 200; +const MAX_FILE_BYTES = 5 * 1024 * 1024; +const MAX_TOTAL_BYTES = 20 * 1024 * 1024; /** * Strips the content off a LibrarySkill: the API only sends metadata; the full body is @@ -61,6 +79,145 @@ function libraryResponse(): SkillLibraryResponse { }; } +/** + * Validates one zip entry path (zip-slip guard): rejects absolute paths (leading "/" or a + * drive letter), backslashes and any ".." segment — a malicious archive must never write + * outside the target Skill directory. + */ +function assertSafeEntryPath(name: string): void { + if (name.includes("\\")) throw badRequest(`Invalid zip entry path (backslash): ${name}`); + if (name.startsWith("/") || /^[A-Za-z]:/.test(name)) { + throw badRequest(`Invalid zip entry path (absolute): ${name}`); + } + if (name.split("/").some((segment) => segment === "..")) { + throw badRequest(`Invalid zip entry path (traversal): ${name}`); + } +} + +/** A skill decoded from an uploaded zip: name + file bytes keyed by path relative to the skill directory. */ +interface ArchiveSkill { + name: string; + files: Map; +} + +/** + * Decodes and validates an uploaded skill zip. Accepted layouts: SKILL.md at the zip root + * (name comes from frontmatter), or exactly one top-level directory containing SKILL.md + * (the directory name is the Skill name, consistent with listInstalledSkills where the + * directory name always wins). Directory entries are ignored (paths recreate them); every + * file path is zip-slip-checked and the count/size limits enforced before anything is + * returned. Frontmatter must parse to a non-null name, and the resolved Skill name must + * match SKILL_NAME_PATTERN. + */ +function parseSkillArchive(archive: Buffer): ArchiveSkill { + let entries: Record; + try { + entries = unzipSync(new Uint8Array(archive)); + } catch { + throw badRequest("dataBase64 is not a valid zip archive."); + } + const files = Object.entries(entries).filter(([name]) => !name.endsWith("/")); + if (files.length === 0) throw badRequest("The zip archive contains no files."); + if (files.length > MAX_ARCHIVE_FILES) { + throw badRequest(`The zip archive exceeds the ${MAX_ARCHIVE_FILES}-file limit.`); + } + let total = 0; + for (const [name, data] of files) { + assertSafeEntryPath(name); + if (data.byteLength > MAX_FILE_BYTES) { + throw badRequest(`Zip entry exceeds the 5MB uncompressed limit: ${name}`); + } + total += data.byteLength; + if (total > MAX_TOTAL_BYTES) { + throw badRequest("The zip archive exceeds the 20MB uncompressed limit."); + } + } + const names = files.map(([name]) => name); + let prefix = ""; + let dirName: string | undefined; + if (!names.includes("SKILL.md")) { + const topLevels = new Set(names.map((name) => name.split("/", 1)[0]!)); + dirName = topLevels.size === 1 ? [...topLevels][0] : undefined; + if (dirName === undefined || !names.includes(`${dirName}/SKILL.md`)) { + throw badRequest( + "The zip must contain SKILL.md at its root, or exactly one top-level directory containing SKILL.md.", + ); + } + prefix = `${dirName}/`; + } + const meta = parseSkillFrontmatter(strFromU8(entries[`${prefix}SKILL.md`]!)); + if (meta === null) { + throw badRequest("SKILL.md must start with a frontmatter block that sets `name`."); + } + const name = dirName ?? meta.name; + if (!SKILL_NAME_PATTERN.test(name)) { + throw badRequest( + `Invalid skill name ${JSON.stringify(name)}: only letters, digits, "_" and "-" are allowed.`, + ); + } + return { name, files: new Map(files.map(([n, data]) => [n.slice(prefix.length), data])) }; +} + +/** + * Recursively collects an installed skill directory as zip entries under a single + * top-level `/` directory (subpaths preserved, "/" separators), so the export + * round-trips through the POST archive route unchanged. Symlinks and other non-regular + * entries are skipped (nothing outside the directory can leak). The import caps apply + * on the way out too — a directory exceeding them couldn't be re-imported anyway. + */ +async function collectSkillArchive(dir: string, name: string): Promise> { + const out: Record = {}; + let count = 0; + let total = 0; + const walk = async (abs: string, rel: string): Promise => { + for (const entry of await fs.readdir(abs, { withFileTypes: true })) { + const absChild = path.join(abs, entry.name); + const relChild = `${rel}/${entry.name}`; + if (entry.isDirectory()) { + await walk(absChild, relChild); + continue; + } + if (!entry.isFile()) continue; + count += 1; + const data = new Uint8Array(await fs.readFile(absChild)); + total += data.byteLength; + if ( + count > MAX_ARCHIVE_FILES || + data.byteLength > MAX_FILE_BYTES || + total > MAX_TOTAL_BYTES + ) { + throw new HttpError( + 413, + "skill_too_large", + `Skill directory exceeds the archive limits (${MAX_ARCHIVE_FILES} files, 5MB per file, 20MB total).`, + ); + } + out[relChild] = data; + } + }; + await walk(dir, name); + return out; +} + +/** + * Version for the export filename: only an explicit frontmatter `version:` field that + * parses as a natural number yields a `-v` filename suffix — parseSkillFrontmatter + * defaults a missing field to 1, which must not be baked into a filename as if declared. + * Mirrors the parser's frontmatter rules (first `---` block, `key: value` split on the + * first colon, BOM/CRLF tolerated). + */ +function explicitSkillVersion(skillMd: string): number | null { + const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMd.replace(/^\uFEFF/, ""))?.[1]; + if (block === undefined) return null; + for (const line of block.split(/\r?\n/)) { + const idx = line.indexOf(":"); + if (idx <= 0 || line.slice(0, idx).trim() !== "version") continue; + const version = Number.parseInt(line.slice(idx + 1).trim(), 10); + return Number.isInteger(version) && version >= 1 ? version : null; + } + return null; +} + /** Validate the POST request body: names must be a non-empty array of strings. */ function parseInstallNames(body: Record): string[] { if (!Array.isArray(body.names) || body.names.length === 0) { @@ -119,6 +276,81 @@ export function agentSkillsRoutes(deps: AppDeps): Hono { return c.json(await listResponse(projectId, agentId), 201); }); + // Install one skill from an uploaded zip. Like the library POST this touches only the + // files (no runtime invalidation): skills are read from disk on demand, so the next + // prompt assembly already sees the new content. + app.post("/archive", async (c) => { + const projectId = requireValidId(c, "projectId"); + const agentId = requireValidId(c, "agentId"); + deps.projectService.requireProjectAccess(c.var.user.userId, projectId); + await deps.agentConfigService.requireExists(projectId, agentId); + const body = await readJson(c); + const dataBase64 = requireString(body, "dataBase64", { minLen: 1, maxLen: 20 * 1024 * 1024 }); + const overwrite = body.overwrite === true; + let archive: Buffer; + try { + archive = Buffer.from(dataBase64, "base64"); + } catch { + throw badRequest("dataBase64 is not valid base64."); + } + if (archive.byteLength === 0) throw badRequest("The zip archive is empty."); + if (archive.byteLength > MAX_ARCHIVE_BYTES) { + throw badRequest("The zip archive exceeds the 14MB limit."); + } + const skill = parseSkillArchive(archive); + const dir = path.join(skillsDir(deps.config.root, projectId, agentId), skill.name); + // Installed-check uses the same criterion as listInstalledSkills: skills//SKILL.md exists. + if (!overwrite) { + const installed = await fs.access(path.join(dir, "SKILL.md")).then( + () => true, + () => false, + ); + if (installed) { + throw new HttpError(409, "skill_exists", `Skill is already installed: ${skill.name}`); + } + } + // Replace semantics (same as reinstalling from the library): drop the old directory + // first so no stale file survives, then write every archive file (subdirectories kept). + await fs.rm(dir, { recursive: true, force: true }); + for (const [rel, data] of skill.files) { + const file = path.join(dir, rel); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, data); + } + return c.json(await listResponse(projectId, agentId), 201); + }); + + // Export one installed skill as a zip: served verbatim as an attachment (same shape as + // the snapshot export / trace download — a direct binary body, not a JSON envelope), so + // what's downloaded can be re-imported byte-compatibly via the POST archive route. + app.get("/:name/archive", async (c) => { + const projectId = requireValidId(c, "projectId"); + const agentId = requireValidId(c, "agentId"); + deps.projectService.requireProjectAccess(c.var.user.userId, projectId); + const name = requireValidId(c, "name"); + const dir = path.join(skillsDir(deps.config.root, projectId, agentId), name); + // Installed-check uses the same criterion as listInstalledSkills: skills//SKILL.md exists. + try { + await fs.access(path.join(dir, "SKILL.md")); + } catch { + throw new HttpError(404, "not_found", `Skill is not installed: ${name}`); + } + const archiveFiles = await collectSkillArchive(dir, name); + // A -v suffix only when the frontmatter declares one explicitly (the header + // is the authority on the filename — the web tab reads it from Content-Disposition). + const version = explicitSkillVersion(strFromU8(archiveFiles[`${name}/SKILL.md`]!)); + const fileName = version === null ? `${name}.zip` : `${name}-v${version}.zip`; + const zip = zipSync(archiveFiles); + return new Response(new Uint8Array(zip), { + headers: { + "Content-Type": "application/zip", + // The name is id-validated ([A-Za-z0-9_-]+), so the encoded filename is itself. + "Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`, + "X-Content-Type-Options": "nosniff", + }, + }); + }); + app.delete("/:name", async (c) => { const projectId = requireValidId(c, "projectId"); const agentId = requireValidId(c, "agentId"); diff --git a/packages/server/test/skills.test.ts b/packages/server/test/skills.test.ts index 096c971..f376660 100644 --- a/packages/server/test/skills.test.ts +++ b/packages/server/test/skills.test.ts @@ -2,11 +2,14 @@ * Integration tests for the Skill routes: library catalog structure (any logged-in user), member * install/uninstall with 404 for outsiders, 404 for unknown skills, installed * files matching the library content, idempotent update on reinstall, the - * directory disappearing after uninstall, and default_agent starting with all - * skills installed while a newly created plain Agent has none. + * directory disappearing after uninstall, default_agent starting with all + * skills installed while a newly created plain Agent has none, and the zip + * archive install/export (layouts, zip-slip and limit rejections, 409 + * skill_exists + overwrite replace, byte-identical export round-trip). */ import fs from "node:fs/promises"; import path from "node:path"; +import { strToU8, unzipSync, zipSync } from "fflate"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { skillsDir } from "@prismshadow/penguin-core"; import { librarySkill, loadLibrarySkills } from "@prismshadow/penguin-skills"; @@ -191,6 +194,8 @@ describe("skills api", () => { const url = base("default_agent"); expect((await outsider.get(url)).status).toBe(404); expect((await outsider.post(url, { names: ["penguin-sdk"] })).status).toBe(404); + expect((await outsider.post(`${url}/archive`, { dataBase64: "AAAA" })).status).toBe(404); + expect((await outsider.get(`${url}/penguin-sdk/archive`)).status).toBe(404); expect((await outsider.delete(`${url}/penguin-sdk`)).status).toBe(404); // The library catalog isn't scoped under a Project prefix: any logged-in user can read it. expect((await outsider.get("/api/skills")).status).toBe(200); @@ -217,4 +222,222 @@ describe("skills api", () => { const fresh = (await (await member.get(base("fresh_agent"))).json()) as AgentSkillsResponse; expect(fresh.skills).toEqual([]); }); + + // ---- POST .../skills/archive: install one skill from an uploaded zip ---- + + const ZIP_SKILL_MD = + "---\nname: zip-skill\ndescription: Zip demo skill\nshort_description: Zip demo\nversion: 2\nupdated: 2026-08-01\n---\n\n# Zip skill\nBody.\n"; + + /** Builds an in-memory zip and returns it base64-encoded (the request wire format). */ + const zipB64 = (files: Record): string => + Buffer.from(zipSync(files)).toString("base64"); + + it("archive: nested top-dir layout — all files written (subdirs preserved), directory name wins over frontmatter", async () => { + await createPlainAgent("zip_agent"); + const url = `${base("zip_agent")}/archive`; + // Frontmatter says zip-skill, but the top-level directory is dir-skill: the directory + // name is the identity (same rule as listInstalledSkills). The explicit directory + // entry ("dir-skill/") must be ignored, not treated as a file. + const res = await member.post(url, { + dataBase64: zipB64({ + "dir-skill/": new Uint8Array(0), + "dir-skill/SKILL.md": strToU8(ZIP_SKILL_MD), + "dir-skill/ref/notes.md": strToU8("notes\n"), + }), + }); + expect(res.status).toBe(201); + const body = (await res.json()) as AgentSkillsResponse; + expect(body.skills.map((s) => s.name)).toEqual(["dir-skill"]); + expect(body.skills[0]!.version).toBe(2); + expect(body.skills[0]!.shortDescription).toBe("Zip demo"); + const dir = path.join(skillsDir(t.root, projectId, "zip_agent"), "dir-skill"); + expect(await fs.readFile(path.join(dir, "SKILL.md"), "utf8")).toBe(ZIP_SKILL_MD); + expect(await fs.readFile(path.join(dir, "ref", "notes.md"), "utf8")).toBe("notes\n"); + }); + + it("archive: root layout takes the name from frontmatter; uninstall works on the archive-installed skill", async () => { + await createPlainAgent("zip_root_agent"); + const url = base("zip_root_agent"); + const res = await member.post(`${url}/archive`, { + dataBase64: zipB64({ "SKILL.md": strToU8(ZIP_SKILL_MD) }), + }); + expect(res.status).toBe(201); + const body = (await res.json()) as AgentSkillsResponse; + expect(body.skills.map((s) => s.name)).toEqual(["zip-skill"]); + // Uninstall goes through the same DELETE route as library skills: 204, directory gone. + expect((await member.delete(`${url}/zip-skill`)).status).toBe(204); + await expect( + fs.access(path.join(skillsDir(t.root, projectId, "zip_root_agent"), "zip-skill")), + ).rejects.toThrow(); + const after = (await (await member.get(url)).json()) as AgentSkillsResponse; + expect(after.skills).toEqual([]); + }); + + it("archive: zip-slip and unsafe entry paths are rejected with 400, nothing written", async () => { + await createPlainAgent("zip_slip_agent"); + const url = base("zip_slip_agent"); + const unsafe = ["../evil.md", "/abs.md", "C:/win.md", "a\\b.md"]; + for (const entry of unsafe) { + const res = await owner.post(`${url}/archive`, { + dataBase64: zipB64({ + "zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD), + [entry]: strToU8("x"), + }), + }); + expect(res.status, entry).toBe(400); + } + const list = (await (await owner.get(url)).json()) as AgentSkillsResponse; + expect(list.skills).toEqual([]); + }); + + it("archive: invalid skill names are rejected (top-level dir and frontmatter name)", async () => { + await createPlainAgent("zip_name_agent"); + const url = `${base("zip_name_agent")}/archive`; + // Top-level directory name with a space fails SKILL_NAME_PATTERN. + const badDir = await owner.post(url, { + dataBase64: zipB64({ "bad name/SKILL.md": strToU8(ZIP_SKILL_MD) }), + }); + expect(badDir.status).toBe(400); + // Root layout: the frontmatter name is the skill name and must pass the same rule. + const badMeta = await owner.post(url, { + dataBase64: zipB64({ + "SKILL.md": strToU8("---\nname: bad/name\ndescription: d\n---\nbody\n"), + }), + }); + expect(badMeta.status).toBe(400); + }); + + it("archive: malformed bodies and layouts are rejected with 400", async () => { + await createPlainAgent("zip_shape_agent"); + const url = `${base("zip_shape_agent")}/archive`; + const cases: Array<[string, Record]> = [ + ["dataBase64 missing", {}], + ["not a zip", { dataBase64: Buffer.from("not a zip").toString("base64") }], + [ + "two top-level directories", + { + dataBase64: zipB64({ + "one/SKILL.md": strToU8(ZIP_SKILL_MD), + "two/readme.md": strToU8("x"), + }), + }, + ], + ["no SKILL.md anywhere", { dataBase64: zipB64({ "sub/readme.md": strToU8("x") }) }], + [ + "frontmatter without name", + { dataBase64: zipB64({ "SKILL.md": strToU8("no frontmatter here\n") }) }, + ], + ]; + for (const [label, body] of cases) { + expect((await owner.post(url, body)).status, label).toBe(400); + } + }); + + it("archive: uncompressed limits — file count, per-file size, total size", async () => { + await createPlainAgent("zip_limit_agent"); + const url = `${base("zip_limit_agent")}/archive`; + // > 200 files. + const many: Record = { "zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD) }; + for (let i = 0; i < 201; i++) many[`zip-skill/f${i}.txt`] = strToU8("x"); + expect((await owner.post(url, { dataBase64: zipB64(many) })).status).toBe(400); + // Per-file > 5MB uncompressed (zeros compress tiny, so the wire stays small). + const big: Record = { + "zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD), + "zip-skill/big.bin": new Uint8Array(5 * 1024 * 1024 + 1), + }; + expect((await owner.post(url, { dataBase64: zipB64(big) })).status).toBe(400); + // Total > 20MB uncompressed across files that each stay under the per-file cap. + const total: Record = { "zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD) }; + for (let i = 0; i < 5; i++) { + total[`zip-skill/part${i}.bin`] = new Uint8Array(4200 * 1024); + } + expect((await owner.post(url, { dataBase64: zipB64(total) })).status).toBe(400); + }); + + it("archive: already installed is 409 skill_exists; overwrite replaces the directory (stale files removed)", async () => { + await createPlainAgent("zip_over_agent"); + const url = `${base("zip_over_agent")}/archive`; + const first = await member.post(url, { + dataBase64: zipB64({ + "zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD), + "zip-skill/old.txt": strToU8("old\n"), + }), + }); + expect(first.status).toBe(201); + + // Same name again without overwrite: 409 with the name in the message (the web tab + // reads it from there for the overwrite confirmation copy). + const again = await member.post(url, { + dataBase64: zipB64({ "zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD) }), + }); + expect(again.status).toBe(409); + const err = (await again.json()) as { error: { code: string; message: string } }; + expect(err.error.code).toBe("skill_exists"); + expect(err.error.message).toMatch(/: zip-skill$/); + + // overwrite: true replaces the whole directory: old.txt is gone, new.txt appears. + const updatedMd = ZIP_SKILL_MD.replace("version: 2", "version: 3"); + const res = await member.post(url, { + dataBase64: zipB64({ + "zip-skill/SKILL.md": strToU8(updatedMd), + "zip-skill/new.txt": strToU8("new\n"), + }), + overwrite: true, + }); + expect(res.status).toBe(201); + const body = (await res.json()) as AgentSkillsResponse; + expect(body.skills.find((s) => s.name === "zip-skill")!.version).toBe(3); + const dir = path.join(skillsDir(t.root, projectId, "zip_over_agent"), "zip-skill"); + expect(await fs.readFile(path.join(dir, "SKILL.md"), "utf8")).toBe(updatedMd); + expect(await fs.readFile(path.join(dir, "new.txt"), "utf8")).toBe("new\n"); + await expect(fs.access(path.join(dir, "old.txt"))).rejects.toThrow(); + }); + + it("archive export: single-top-dir zip round-trips byte-identically; a non-installed name is 404", async () => { + await createPlainAgent("zip_export_agent"); + const url = base("zip_export_agent"); + // Install a multi-file skill through the archive route (nested subdir + icon.svg). + const files: Record = { + "zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD), + "zip-skill/icon.svg": strToU8('\n'), + "zip-skill/ref/notes.md": strToU8("notes\n"), + }; + expect((await member.post(`${url}/archive`, { dataBase64: zipB64(files) })).status).toBe(201); + + // Export it: a direct binary attachment (application/zip), like the snapshot export. + // The frontmatter declares version: 2 explicitly, so the filename carries -v2. + const res = await member.get(`${url}/zip-skill/archive`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/zip"); + expect(res.headers.get("content-disposition")).toBe( + "attachment; filename*=UTF-8''zip-skill-v2.zip", + ); + const entries = unzipSync(new Uint8Array(await res.arrayBuffer())); + // Single-top-dir layout with every installed file, byte-identical to the upload — the + // export feeds back into the POST archive route unchanged. + const fileNames = Object.keys(entries).filter((n) => !n.endsWith("/")); + expect(fileNames.sort()).toEqual(Object.keys(files).sort()); + for (const [name, data] of Object.entries(files)) { + expect(Buffer.from(entries[name]!)).toEqual(Buffer.from(data)); + } + + // Exporting a skill that isn't installed → 404 (same criterion as uninstall). + expect((await member.get(`${url}/no-such-skill/archive`)).status).toBe(404); + + // Without an explicit frontmatter version: field the filename stays .zip — + // parseSkillFrontmatter's defaulted 1 must not be presented as a declared version. + const noVersion = "---\nname: nover-skill\ndescription: No version field\n---\nbody\n"; + expect( + ( + await member.post(`${url}/archive`, { + dataBase64: zipB64({ "SKILL.md": strToU8(noVersion) }), + }) + ).status, + ).toBe(201); + const plain = await member.get(`${url}/nover-skill/archive`); + expect(plain.status).toBe(200); + expect(plain.headers.get("content-disposition")).toBe( + "attachment; filename*=UTF-8''nover-skill.zip", + ); + }); }); diff --git a/packages/skills/src/index.ts b/packages/skills/src/index.ts index 739d5af..d512a27 100644 --- a/packages/skills/src/index.ts +++ b/packages/skills/src/index.ts @@ -89,8 +89,8 @@ export function parseSkillFrontmatter(content: string): SkillMetadata | null { /** Root directory of library files: the package's `skills/` (both dist/ and src/ sit one level below the package root, so one level up reaches it). */ const SKILLS_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "skills"); -/** Character rule for Skill names (directory names): prevents path traversal. */ -const SKILL_NAME_PATTERN = /^[A-Za-z0-9_-]+$/; +/** Character rule for Skill names (directory names): prevents path traversal (exported for the server's archive-install validation). */ +export const SKILL_NAME_PATTERN = /^[A-Za-z0-9_-]+$/; /** * Reads a single library directory to construct a LibrarySkill; returns undefined if SKILL.md diff --git a/packages/web/src/api/endpoints.ts b/packages/web/src/api/endpoints.ts index e351a1f..bd8372e 100644 --- a/packages/web/src/api/endpoints.ts +++ b/packages/web/src/api/endpoints.ts @@ -54,6 +54,7 @@ import type { SessionResponse, SessionsResponse, SessionTracesResponse, + SkillArchiveInstallRequest, SkillInstallRequest, SkillLibraryResponse, RetryNowResponse, @@ -527,6 +528,23 @@ export const installAgentSkills = (projectId: string, agentId: string, names: st { method: "POST", body: { names } satisfies SkillInstallRequest }, ); +/** Installs one skill from an uploaded zip (base64); 409 skill_exists unless overwrite; 201 returns the latest installed list. */ +export const installAgentSkillArchive = ( + projectId: string, + agentId: string, + body: SkillArchiveInstallRequest, +) => + apiFetch( + `/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}` + + `/skills/archive`, + { method: "POST", body }, + ); + +/** Zip download URL for one installed skill (server sets Content-Disposition attachment); the export round-trips through installAgentSkillArchive. */ +export const agentSkillArchiveUrl = (projectId: string, agentId: string, name: string): string => + `/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}` + + `/skills/${encodeURIComponent(name)}/archive`; + export const removeAgentSkill = (projectId: string, agentId: string, name: string) => apiFetch( `/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}` + diff --git a/packages/web/src/features/agents/agent-settings-page.tsx b/packages/web/src/features/agents/agent-settings-page.tsx index 3ce0e38..822b158 100644 --- a/packages/web/src/features/agents/agent-settings-page.tsx +++ b/packages/web/src/features/agents/agent-settings-page.tsx @@ -1,10 +1,10 @@ /** - * Agent settings page: six tabs — + * Agent settings page: seven tabs — * Overview (name/description/State path/active count/State version + snapshot * export-import + restore default configuration), Prompt (AGENTS.md and system_prompt editors + placeholder * reference), Runtime (max_turns, model.*, compaction.*), Tools (editable built-in - * tools table, MCP Server read-only JSON), Vault (vault-tab.tsx), Schedule - * (schedules-tab.tsx). + * tools table, MCP Server read-only JSON), Skills (skills-tab.tsx), Vault + * (vault-tab.tsx), Schedule (schedules-tab.tsx). * Save = PUT config (sends only the changed keys; YAML comments are preserved * server-side). */ @@ -33,11 +33,12 @@ import { OptionMenu, type OptionMenuChoice } from "../../components/ui/option-me import { Switch } from "../../components/ui/switch"; import { ConfirmModal, useSaveConfirm } from "../../components/ui/confirm-modal"; import { Skeleton } from "../../components/ui/skeleton"; +import { SkillsTab } from "./skills-tab"; import { VaultTab } from "./vault-tab"; import { SchedulesTab } from "./schedules-tab"; import { thinkingLevelOptionsFor } from "../chat/thinking-level"; -type TabKey = "overview" | "prompt" | "runtime" | "tools" | "vault" | "schedules"; +type TabKey = "overview" | "prompt" | "runtime" | "tools" | "skills" | "vault" | "schedules"; /** * Dropdown rows from a dictionary's [value, description] pairs (exported for unit tests). @@ -92,6 +93,7 @@ export function AgentSettingsPage() { { key: "prompt", label: S.agent.tabPrompt }, { key: "runtime", label: S.agent.tabRuntime }, { key: "tools", label: S.agent.tabTools }, + { key: "skills", label: S.agent.tabSkills }, { key: "vault", label: S.agent.tabVault }, { key: "schedules", label: S.agent.tabSchedules }, ] as const; @@ -232,6 +234,7 @@ export function AgentSettingsPage() { {tab === "prompt" && } {tab === "runtime" && } {tab === "tools" && } + {tab === "skills" && } {tab === "vault" && } {tab === "schedules" && } diff --git a/packages/web/src/features/agents/skill-import-source.ts b/packages/web/src/features/agents/skill-import-source.ts new file mode 100644 index 0000000..e844980 --- /dev/null +++ b/packages/web/src/features/agents/skill-import-source.ts @@ -0,0 +1,51 @@ +/** + * Import-source classification for the Skills tab's import dialog (pure logic, unit + * tested): the source field accepts more than a webpage URL — a GitHub repo or directory + * URL, a local filesystem path, an install command from another ecosystem (whose plugins + * are essentially skill files; PenguinHarness has no plugin mechanism of its own), or a + * bare plugin/marketplace reference. The generated chat prompt tailors its lead sentence + * per kind; the security tail (read fully, review for malicious instructions, then + * install) is shared and always appended. + */ +import { S } from "../../lib/strings"; + +/** How the pasted source should be approached by the agent (one lead sentence per kind in S.skills.importPromptLead). */ +export type ImportSourceKind = "webUrl" | "repoUrl" | "localPath" | "command" | "reference"; + +/** Hosts whose URLs point at a repository or a directory within one (clone / fetch and locate SKILL.md dirs). */ +const REPO_HOSTS = /^(www\.)?(github|gitlab|gitee|bitbucket)\.(com|org)$/i; + +/** + * Lightly classifies a pasted source. Heuristic on purpose — the result only picks the + * prompt's lead sentence, and a miss still yields a safe, workable instruction: + * - repoUrl: scp-style git remote (git@host:…), any *.git reference, or an http(s) URL + * on a known forge host (repo or directory link); + * - webUrl: any other http(s) URL; + * - localPath: absolute / home / dot-relative / Windows-drive / UNC paths; + * - command: a leading executable word followed by arguments (npx skills add …); + * - reference: anything else (marketplace reference, bare skill name). + */ +export function classifyImportSource(input: string): ImportSourceKind { + const s = input.trim(); + if (/^git@[\w.-]+:/.test(s) || /\.git$/i.test(s)) return "repoUrl"; + if (/^https?:\/\//i.test(s)) { + try { + if (REPO_HOSTS.test(new URL(s).hostname)) return "repoUrl"; + } catch { + // Malformed URL-ish input: keep the generic web page treatment. + } + return "webUrl"; + } + if (/^(\/|~\/|\.{1,2}\/|[A-Za-z]:[\\/]|\\\\)/.test(s)) return "localPath"; + if (/^[A-Za-z@][\w@./-]*\s+\S/.test(s)) return "command"; + return "reference"; +} + +/** + * Localized install prompt for one source: the per-kind lead sentence plus the shared + * security/skill-porting tail on a second line. Reads S at call time (live binding), so + * it follows language switches like every other string consumer. + */ +export function buildImportPrompt(input: string): string { + return `${S.skills.importPromptLead[classifyImportSource(input)](input)}\n${S.skills.importPromptTail}`; +} diff --git a/packages/web/src/features/agents/skills-tab.tsx b/packages/web/src/features/agents/skills-tab.tsx new file mode 100644 index 0000000..faec1b8 --- /dev/null +++ b/packages/web/src/features/agents/skills-tab.tsx @@ -0,0 +1,428 @@ +/** + * Agent settings page "Skills" tab: the skills installed on this Agent + * (agent_state/skills// — the files are the single source of truth, so the list is + * re-fetched from the API after every mutation instead of trusting client state). Rows show + * the skill icon, name, localized short description and version/updated metadata; uninstall + * confirms first (deletes the whole directory, local edits included). The "Import skill" + * modal offers two paths: the recommended chat install (a source field accepting a web + * page / repo URL / local path / foreign install command — see skill-import-source.ts — + * whose generated review-then-install prompt can be copied or prefilled into a new chat + * with this Agent) and a zip upload posted base64 to the archive endpoint (409 + * skill_exists asks before overwriting). Each row can also export the installed directory + * as a zip that round-trips through that same endpoint. Read and mutate are both + * member-level, matching the skills routes — no owner gating here. + */ +import { useCallback, useEffect, useState } from "react"; +import type { ChangeEvent } from "react"; +import { useNavigate } from "react-router"; +import type { SkillMetadataItem } from "@prismshadow/penguin-server/api"; +import * as api from "../../api/endpoints"; +import { ApiError } from "../../api/client"; +import { S } from "../../lib/strings"; +import { apiErrorText } from "../../lib/api-error"; +import { formatRelativeDate } from "../../lib/format"; +import { useAuth } from "../../state/auth"; +import { useLocale } from "../../state/locale"; +import { agentDisplayName, useProject } from "../../state/project"; +import { Button } from "../../components/ui/button"; +import { GlyphIcon } from "../../components/ui/glyph-icon"; +import { Input, Textarea } from "../../components/ui/input"; +import { Modal } from "../../components/ui/modal"; +import { ConfirmModal } from "../../components/ui/confirm-modal"; +import { DownloadIcon } from "../../components/ui/icons"; +import { HiddenFileInput } from "../../components/ui/hidden-file-input"; +import { SkeletonList } from "../../components/ui/skeleton"; +import { toastError, toastSuccess } from "../../components/ui/toast"; +import { SkillIcon, skillTileColor } from "../skills/skill-icon-view"; +import { localizedShortText } from "../chat/skill-use"; +import { DRAFT_SESSION_ID } from "../chat/chat-page"; +import { draftKey, loadDraft, saveDraft } from "../chat/draft-cache"; +import { buildImportPrompt } from "./skill-import-source"; + +/**