feat(skills): agent settings Skills tab with uninstall and archive/chat import (#179)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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/<name>/;
|
||||
* 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/<name>/ (replace semantics with
|
||||
* `overwrite`), GET packs the whole directory back under a single top-level <name>/ 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<string, Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, Uint8Array>;
|
||||
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 `<name>/` 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<Record<string, Uint8Array>> {
|
||||
const out: Record<string, Uint8Array> = {};
|
||||
let count = 0;
|
||||
let total = 0;
|
||||
const walk = async (abs: string, rel: string): Promise<void> => {
|
||||
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<version>` 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, unknown>): string[] {
|
||||
if (!Array.isArray(body.names) || body.names.length === 0) {
|
||||
@@ -119,6 +276,81 @@ export function agentSkillsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
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/<name>/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/<name>/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<version> 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");
|
||||
|
||||
@@ -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, Uint8Array>): 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<string, unknown>]> = [
|
||||
["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<string, Uint8Array> = { "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<string, Uint8Array> = {
|
||||
"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<string, Uint8Array> = { "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<string, Uint8Array> = {
|
||||
"zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD),
|
||||
"zip-skill/icon.svg": strToU8('<svg viewBox="0 0 24 24"><path d="M2 2h20"/></svg>\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 <name>.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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user