Commit 9a500dae by DigHuang Committed by GitHub

refactor(skill): offload skill package decompression to sandbox container (#7004)

* refactor(skill): offload skill package decompression to sandbox   container

* refactor(skill): consolidate skill utilities and constants

* refactor(skill): remove dead code and restrict helper function visibility

* refactor(skill): extract gitignore parser to utility and add robust   test coverage
parent 9f07741e
import type { ISandbox, SandboxCreateSpec } from '@fastgpt-sdk/sandbox-adapter';
import { MongoAgentSkills } from '../model/schema';
import { MongoAgentSkillsVersion } from '../version/schema';
import { downloadSkillPackage, extractNormalizedSkillPackageFilesForSandbox } from '../package';
import {
parseSkillMarkdown,
shellQuote,
getSafeSkillDirectoryName,
joinSandboxPath,
parseGitignoreRules
} from '../utils';
import { downloadSkillPackage, DEFAULT_GITIGNORE_CONTENT } from '../package';
import { getSkillSizeLimits } from '../sandbox/config';
import { EDIT_DEBUG_SANDBOX_CHAT_ID, getEditDebugSandboxId } from './config';
import { getSandboxProviderConfig, validateSandboxConfig } from '../../sandbox/provider/config';
......@@ -30,7 +37,6 @@ import {
import { getLogger, LogCategories } from '../../../../common/logger';
import { serviceEnv } from '../../../../env';
import type { SandboxStatusItemType } from '@fastgpt/global/core/chat/type';
import { joinSandboxPath, shellQuote } from '../runtime';
import { checkTeamSandboxPermission } from '../../../../support/permission/teamLimit';
const addLog = getLogger(LogCategories.MODULE.AI.AGENT);
......@@ -292,33 +298,82 @@ export async function createEditDebugSandbox(
);
}
onProgress?.({ sandboxId: sessionId, phase: 'extractingPackage' });
const packageFiles = await extractNormalizedSkillPackageFilesForSandbox(packageBuffer);
const writeEntries = packageFiles.map((file) => ({
path: joinSandboxPath(skillsRootPath, file.path),
data: file.data
}));
const parentDirs = Array.from(
new Set([skillsRootPath, ...writeEntries.map((entry) => getSandboxParentPath(entry.path))])
);
const extractResult = await client.provider.execute(
[
`rm -rf ${shellQuote(skillsRootPath)}`,
`mkdir -p ${parentDirs.map((dir) => shellQuote(dir)).join(' ')}`
].join(' && ')
);
onProgress?.({ sandboxId: sessionId, phase: 'uploadingPackage' });
const zipPath = joinSandboxPath(skillsRootPath, 'package.zip');
if (extractResult.exitCode !== 0) {
throw new Error(`Failed to extract package: ${extractResult.stderr}`);
const prepareResult = await client.provider.execute(
`rm -rf ${shellQuote(skillsRootPath)} && mkdir -p ${shellQuote(skillsRootPath)}`
);
if (prepareResult.exitCode !== 0) {
throw new Error(`Failed to reset skill directory: ${prepareResult.stderr}`);
}
onProgress?.({ sandboxId: sessionId, phase: 'uploadingPackage' });
const writeResults = await client.provider.writeFiles(writeEntries);
const writeResults = await client.provider.writeFiles([
{
path: zipPath,
data: packageBuffer
}
]);
const failedWrite = writeResults.find((result) => result.error);
if (failedWrite) {
throw new Error(
`Failed to write skill package file ${failedWrite.path}: ${failedWrite.error?.message}`
);
throw new Error(`Failed to write skill package ZIP: ${failedWrite.error?.message}`);
}
const quotedSkillsRootPath = shellQuote(skillsRootPath);
const quotedZipPath = shellQuote(zipPath);
const unzipCmd = [
`cd ${quotedSkillsRootPath}`,
`mkdir -p tmp_unzip`,
`unzip -o -q package.zip -d tmp_unzip`,
`rm -f package.zip`
].join(' && ');
const extractResult = await client.provider.execute(unzipCmd);
if (extractResult.exitCode !== 0) {
throw new Error(`Failed to decompress package inside sandbox: ${extractResult.stderr}`);
}
const findSkillMdResult = await client.provider.execute(
`find ${quotedSkillsRootPath}/tmp_unzip -iname "skill.md" | head -n 1`
);
const skillMdPath = findSkillMdResult.stdout.trim();
let skillFolderName = getSafeSkillDirectoryName(skill.name);
if (skillMdPath) {
try {
const readFilesResult = await client.provider.readFiles([skillMdPath]);
const file = readFilesResult?.[0];
if (file && !file.error) {
const content =
typeof file.content === 'string'
? file.content
: Buffer.from(file.content).toString('utf-8');
const { frontmatter } = parseSkillMarkdown(content);
if (frontmatter.name) {
skillFolderName = getSafeSkillDirectoryName(String(frontmatter.name));
}
}
} catch (err: any) {
addLog.warn('[Sandbox] Failed to read and parse SKILL.md from package', {
sandboxId: sessionId,
error: err.message
});
}
}
const quotedSkillFolderName = shellQuote(skillFolderName);
const moveCmd = [
`cd ${quotedSkillsRootPath}`,
`REAL_SKILL_DIR=$(dirname "$(find tmp_unzip -iname "skill.md" | head -n 1)")`,
`if [ -n "$REAL_SKILL_DIR" ] && [ "$REAL_SKILL_DIR" != "." ]; then mv "$REAL_SKILL_DIR" ${quotedSkillFolderName}; fi`,
`rm -rf tmp_unzip`,
`if [ -f ${quotedSkillFolderName}/.gitignore ]; then mv ${quotedSkillFolderName}/.gitignore ${shellQuote(runtimeProfile.workDirectory)}/ 2>/dev/null || true; fi`,
`if [ ! -f ${shellQuote(runtimeProfile.workDirectory)}/.gitignore ]; then echo ${shellQuote(DEFAULT_GITIGNORE_CONTENT)} > ${shellQuote(runtimeProfile.workDirectory)}/.gitignore; fi`
].join(' && ');
const moveResult = await client.provider.execute(moveCmd);
if (moveResult.exitCode !== 0) {
throw new Error(`Failed to move and organize skill folder: ${moveResult.stderr}`);
}
const newSandboxDoc = await updateSandboxInstanceRecordBySandboxId({
......@@ -426,7 +481,39 @@ export async function packageSkillInSandbox(params: {
})();
const quotedTargetDir = shellQuote(targetDir);
const sizeCheckCmd = `find ${quotedTargetDir} -type f ! -name 'package.zip' -ls 2>/dev/null | awk '{s+=$7} END {print s+0}'`;
let gitignoreContents: string[] = [];
try {
const findIgnoreCmd = `find ${quotedTargetDir} -name '.gitignore' -type f`;
const findIgnoreResult = await newSandbox.execute(findIgnoreCmd);
if (findIgnoreResult.exitCode === 0 && findIgnoreResult.stdout.trim()) {
const ignorePaths = findIgnoreResult.stdout
.split('\n')
.map((p) => p.trim())
.filter(Boolean);
if (ignorePaths.length > 0) {
const files = await newSandbox.readFiles(ignorePaths);
gitignoreContents = files
.filter((file) => file && !file.error)
.map((file) =>
typeof file.content === 'string'
? file.content
: Buffer.from(file.content).toString('utf-8')
);
}
}
} catch (err: any) {
addLog.warn('[Sandbox] Failed to read custom .gitignore files', {
sandboxId,
error: err.message
});
}
const { customExcludes, pruneClause } = parseGitignoreRules(gitignoreContents);
const allExcludes = Array.from(new Set(['package.zip', ...customExcludes]));
const sizeCheckCmd = pruneClause
? `cd ${quotedTargetDir} && find . \\( ${pruneClause} \\) -prune -o -type f ! -name 'package.zip' -ls 2>/dev/null | awk '{s+=$7} END {print s+0}'`
: `cd ${quotedTargetDir} && find . -type f ! -name 'package.zip' -ls 2>/dev/null | awk '{s+=$7} END {print s+0}'`;
const sizeResult = await newSandbox.execute(sizeCheckCmd);
if (sizeResult.exitCode === 0 && sizeResult.stdout.trim()) {
......@@ -438,7 +525,8 @@ export async function packageSkillInSandbox(params: {
}
}
const zipCommand = `cd ${quotedTargetDir} && zip -r package.zip . -x 'package.zip'`;
const excludeArgs = allExcludes.map((pattern) => `-x ${shellQuote(pattern)}`).join(' ');
const zipCommand = `cd ${quotedTargetDir} && zip -r package.zip . ${excludeArgs}`;
const zipResult = await newSandbox.execute(zipCommand);
if (zipResult.exitCode !== 0) {
......
......@@ -9,7 +9,7 @@ import { Types } from '../../../../../common/mongo';
import { mongoSessionRun } from '../../../../../common/mongo/sessionRun';
import { MongoAgentSkills } from '../../model/schema';
import { updateCurrentVersion, updateSkillCreationFailed } from '../update';
import { buildSkillMd, extractSkillNameFromSkillMd } from '../../utils/skillMdTemplate';
import { buildSkillMd, extractSkillNameFromSkillMd } from '../../utils';
import { generateSkillMd } from './skillMdGenerator';
import {
createSkillPackage,
......
import decompress from 'decompress';
export type ArchiveFormat = 'zip' | 'tar' | 'tar.gz';
export type ArchiveFileMap = Record<string, Buffer>;
/**
* 根据文件名识别当前支持的归档格式。
*
* 仅做扩展名判断,用于上传入口的快速校验;真正的压缩包合法性由解压流程兜底。
*/
export function getSupportedArchiveFormat(filename: string): ArchiveFormat | null {
const lower = filename.toLowerCase();
if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) return 'tar.gz';
if (lower.endsWith('.tar')) return 'tar';
if (lower.endsWith('.zip')) return 'zip';
return null;
}
/**
* 过滤常见操作系统自动生成的归档垃圾文件。
*
* 这些文件不属于 skill workspace 内容,保留它们会污染导入后的文件树,并可能影响包体积限制。
*/
export function isIgnoredSystemArchiveEntry(path: string): boolean {
const parts = path.split('/').filter(Boolean);
const filename = parts.at(-1)?.toLowerCase();
if (parts.includes('__MACOSX')) return true;
if (!filename) return false;
return (
filename === '.ds_store' ||
filename === 'thumbs.db' ||
filename === 'desktop.ini' ||
filename.startsWith('._')
);
}
/**
* 将 zip/tar/tar.gz 解压为内存文件表。
*
* 会过滤路径穿越条目,并限制总解压体积,避免导入恶意包时写出目录外文件或触发 OOM。
*/
export async function extractToFileMap(
filePath: string,
maxUncompressedBytes: number
): Promise<ArchiveFileMap> {
const files = await decompress(filePath);
const fileMap: ArchiveFileMap = {};
let totalSize = 0;
for (const file of files) {
if (file.type === 'directory') continue;
const normalized = file.path.replace(/\\/g, '/').replace(/^\/+/, '');
// 归档包来自用户上传,所有相对路径都必须阻断路径穿越。
if (!normalized || normalized.includes('../')) continue;
if (isIgnoredSystemArchiveEntry(normalized)) continue;
totalSize += file.data.length;
if (totalSize > maxUncompressedBytes) {
throw new Error(
`Uncompressed archive exceeds maximum allowed size (${maxUncompressedBytes / 1024 / 1024}MB)`
);
}
fileMap[normalized] = file.data;
}
return fileMap;
}
/**
* 在解压文件表中定位入口 SKILL.md。
*
* 兼容历史单 skill 包:SKILL.md 可以在根目录,也可以在一层目录内。
*/
export function findSkillMdKey(fileMap: ArchiveFileMap): string | null {
const paths = Object.keys(fileMap);
const rootKey = paths.find((p) => !p.includes('/') && p.toLowerCase() === 'skill.md');
if (rootKey) return rootKey;
return (
paths.find((p) => {
const parts = p.split('/');
return parts.length === 2 && parts[1].toLowerCase() === 'skill.md';
}) ?? null
);
}
/**
* 将归档包路径归一到 workspace 根目录。
*
* 用户从不同系统导出的压缩包可能带一层外壳目录,例如
* `my-export/skills/demo/SKILL.md`。导入时只接受 workspace 结构,因此如果
* `skills/` 不在根目录,会剥掉最短的外壳前缀,让后续校验统一检查根目录下的
* `skills/`。
*/
export function normalizeSkillWorkspaceRoot(fileMap: ArchiveFileMap): ArchiveFileMap {
const skillPath = Object.keys(fileMap).find(
(path) => path === 'skills' || path.startsWith('skills/')
);
if (skillPath) return fileMap;
const nestedSkillsPath = Object.keys(fileMap).find((path) => path.includes('/skills/'));
if (!nestedSkillsPath) return fileMap;
return stripRootPrefix(
fileMap,
nestedSkillsPath.slice(0, nestedSkillsPath.indexOf('/skills/') + 1)
);
}
/**
* 判断归档包是否包含新版 workspace skill 结构。
*
* 最新导入格式要求根目录存在 `skills/`,且其下至少有一个文件;不再用根目录或
* 单层目录中的 SKILL.md 作为包合法性的判断依据。
*/
export function hasSkillsDirectoryContent(fileMap: ArchiveFileMap): boolean {
return Object.keys(fileMap).some(
(path) => path.startsWith('skills/') && path.length > 'skills/'.length
);
}
/**
* 根据 SKILL.md 路径得到归档包的根目录前缀。
*/
export function getRootPrefix(skillMdKey: string): string {
const idx = skillMdKey.lastIndexOf('/');
return idx === -1 ? '' : skillMdKey.slice(0, idx + 1);
}
/**
* 去掉单 skill 包的根目录前缀,让后续逻辑以 SKILL.md 所在目录作为包根目录。
*/
export function stripRootPrefix(fileMap: ArchiveFileMap, rootPrefix: string): ArchiveFileMap {
if (!rootPrefix) return fileMap;
const result: ArchiveFileMap = {};
for (const [key, value] of Object.entries(fileMap)) {
const stripped = key.startsWith(rootPrefix) ? key.slice(rootPrefix.length) : key;
if (stripped) result[stripped] = value;
}
return result;
}
/**
* Skill ZIP 包相关常量模板。
*/
export const DEFAULT_GITIGNORE_CONTENT = `# FastGPT Skill ignore patterns
# These patterns define files/folders that should not be packaged when deploying/exporting.
# Node.js dependencies
node_modules/
.npm/
.yarn/
.pnpm-store/
package.zip
# Python dependencies & cache
__pycache__/
*.pyc
*.pyo
*.pyd
.venv/
venv/
env/
.pytest_cache/
*.egg-info/
.eggs/
# Output/Build folders
dist/
build/
.next/
out/
.nuxt/
# IDE/Editor config files
.vscode/
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.swp
*.swo
# OS generated files
.DS_Store
.DS_Store?
._*
__MACOSX/
Thumbs.db
ehthumbs.db
desktop.ini
# Large media & binaries
*.mp4
*.avi
*.mkv
*.mov
*.mp3
*.wav
*.flac
*.tgz
*.tar
*.tar.gz
*.rar
*.7z
*.exe
*.dll
*.so
*.dylib
*.dmg
*.pkg
*.iso
# Log files
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
`;
......@@ -4,7 +4,6 @@
* 集中管理压缩包结构、归档解压、对象存储和 package metadata。调用方只关心“包”的输入输出,
* 不需要知道具体是 ZIP/TAR、S3 key 还是 SKILL.md 元数据解析。
*/
export * from './archiveUtils';
export * from './zipBuilder';
export * from './storage';
export * from './metadata';
export * from './constants';
import type { SkillPackageType } from '@fastgpt/global/core/ai/skill/type';
import { AgentSkillCategoryEnum } from '@fastgpt/global/core/ai/skill/constants';
/**
* 校验 JSON 形式的 skill package 元数据结构。
*
* 这里处理的是对象级 package metadata,不负责 zip/tar 解压,也不解析 SKILL.md。
* 归到 package 模块后,导入、导出和测试可以把“包内容”和“包元数据”放在同一边界内理解。
*/
export function validateSkillPackage(data: any): { valid: boolean; error?: string } {
if (!data || typeof data !== 'object') {
return { valid: false, error: 'Invalid package format' };
}
const { skill } = data;
if (!skill || typeof skill !== 'object') {
return { valid: false, error: 'Missing skill metadata' };
}
if (!skill.name || typeof skill.name !== 'string' || skill.name.trim().length === 0) {
return { valid: false, error: 'Skill name is required' };
}
if (skill.name.length > 50) {
return { valid: false, error: 'Skill name must be less than 50 characters' };
}
if (skill.description && skill.description.length > 500) {
return { valid: false, error: 'Description must be less than 500 characters' };
}
if (skill.category) {
if (!Array.isArray(skill.category)) {
return { valid: false, error: 'Category must be an array' };
}
const validCategories = Object.values(AgentSkillCategoryEnum);
const invalidCategories = skill.category.filter(
(c: string) => !validCategories.includes(c as AgentSkillCategoryEnum)
);
if (invalidCategories.length > 0) {
return { valid: false, error: `Invalid categories: ${invalidCategories.join(', ')}` };
}
}
return { valid: true };
}
/**
* 从 JSON 字符串或对象解析出 SkillPackageType。
*/
export function parseSkillPackage(data: string | object): {
success: boolean;
package?: SkillPackageType;
error?: string;
} {
try {
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
const validation = validateSkillPackage(parsed);
if (!validation.valid) {
return { success: false, error: validation.error };
}
return {
success: true,
package: parsed as SkillPackageType
};
} catch (error) {
return { success: false, error: 'Failed to parse skill package: ' + (error as Error).message };
}
}
/**
* 将 package metadata 中的 skill name 清理成历史兼容的文件名形式。
*/
export function sanitizeSkillName(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]/g, '_')
.replace(/_+/g, '_')
.substring(0, 50);
}
/**
* 创建 JSON package metadata 默认模板。
*/
export function createSkillTemplate(name: string): SkillPackageType {
return {
skill: {
name: name || 'New Skill',
description: 'Enter a description for your skill',
category: [AgentSkillCategoryEnum.other]
}
};
}
......@@ -2,7 +2,7 @@
* Skill 包对象存储服务。
*
* 只负责 Skill ZIP 包和编辑会话制品在私有对象存储中的读写、复制、存在性检查和清理。
* 包内容解析放在 package/archiveUtils 与 package/zipBuilder,版本落库放在 version 模块。
* 包内容解析放在 package/zipBuilder,版本落库放在 version 模块。
*/
import { getS3SkillSource } from '../../../../common/s3/sources/skill';
......@@ -158,67 +158,3 @@ export async function copySkillPackage(
zipBuffer
});
}
/**
* 获取会话制品列表
*/
export async function listSessionArtifacts(sessionId: string): Promise<string[]> {
const prefix = `agent-sessions/${sessionId}/`;
const bucket = getS3SkillSource();
const { keys } = await bucket.client.listObjects({ prefix });
return keys.map((key) => key.replace(prefix, ''));
}
/**
* 下载制品
*/
export async function downloadSessionArtifact(
sessionId: string,
filePath: string
): Promise<Buffer> {
const key = `agent-sessions/${sessionId}/${filePath}`;
const bucket = getS3SkillSource();
const response = await bucket.client.downloadObject({ key });
if (!response.body) {
throw new Error(`Failed to download artifact: ${key}`);
}
const chunks: Buffer[] = [];
for await (const chunk of response.body) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
/**
* 清理单个会话的所有制品
*/
export async function cleanSessionArtifacts(sessionId: string): Promise<{ deletedCount: number }> {
const prefix = `agent-sessions/${sessionId}/`;
const bucket = getS3SkillSource();
const { keys: failedKeys } = await bucket.client.deleteObjectsByPrefix({ prefix });
// deleteObjectsByPrefix 不返回实际删除数量,以 0 失败 key 数为成功标志
return { deletedCount: failedKeys.length === 0 ? 1 : 0 };
}
/**
* 批量清理多个会话的制品
*/
export async function cleanExpiredSessionArtifacts(
sessionIds: string[]
): Promise<{ deletedCount: number }> {
let totalDeleted = 0;
for (const sessionId of sessionIds) {
const { deletedCount } = await cleanSessionArtifacts(sessionId);
totalDeleted += deletedCount;
}
return { deletedCount: totalDeleted };
}
......@@ -2,7 +2,7 @@
* Skill ZIP 包构建与标准化工具。
*
* 这里处理 ZIP 结构本身:创建、校验、抽取、重新打包和写入 sandbox 前的路径归一化。
* package/storage 负责对象存储,package/archiveUtils 负责更通用的 zip/tar 解压。
* package/storage 负责对象存储,这里负责 ZIP 包构建与解压。
*
* 多 skill ZIP 结构示例:
* package.zip/
......@@ -14,7 +14,8 @@
*/
import JSZip from 'jszip';
import { extractSkillNameFromSkillMd } from '../utils/skillMdTemplate';
import { extractSkillNameFromSkillMd } from '../utils';
import { DEFAULT_GITIGNORE_CONTENT } from './constants';
// 测试用例需要直接构造 ZIP,因此这里保留 JSZip 的再导出。
export { JSZip };
......@@ -78,6 +79,15 @@ export async function createSkillPackage(params: CreateSkillPackageParams): Prom
// SKILL.md 是 skill 包的必需入口文件。
zip.file(`${rootDir}/SKILL.md`, skillMd);
// Auto-generate a comprehensive default .gitignore if not present
const hasGitignore =
(assets && (assets['.gitignore'] || assets['/.gitignore'])) ||
(additionalFiles && (additionalFiles['.gitignore'] || additionalFiles['/.gitignore']));
if (!hasGitignore) {
zip.file(`${rootDir}/.gitignore`, DEFAULT_GITIGNORE_CONTENT);
}
// Add assets (optional)
if (assets) {
Object.entries(assets).forEach(([path, content]) => {
......@@ -99,11 +109,7 @@ export async function createSkillPackage(params: CreateSkillPackageParams): Prom
/**
* 向 ZIP 中写入单个文件,并统一处理 Buffer、Uint8Array 和字符串内容。
*/
export function addFileToZip(
zip: JSZip,
path: string,
content: Buffer | string | Uint8Array
): void {
function addFileToZip(zip: JSZip, path: string, content: Buffer | string | Uint8Array): void {
// ZIP 内路径不应带绝对路径前缀。
const normalizedPath = path.replace(/^\/+/, '');
......@@ -119,7 +125,7 @@ export function addFileToZip(
/**
* 将 JSZip 实例压缩成 Node Buffer。
*/
export async function generateZipBuffer(zip: JSZip): Promise<Buffer> {
async function generateZipBuffer(zip: JSZip): Promise<Buffer> {
return zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
......@@ -308,138 +314,6 @@ export async function standardizeSkillPackageBySkillMdName(
}
/**
* 将内存文件表重新打成 ZIP。
*
* fileMap 的 key 会原样作为 ZIP 内路径写入,调用方需要在传入前完成路径安全处理。
*/
export async function repackFileMapAsZip(fileMap: Record<string, Buffer>): Promise<Buffer> {
const zip = new JSZip();
for (const [path, content] of Object.entries(fileMap)) {
zip.file(path, content);
}
return generateZipBuffer(zip);
}
function normalizeZipEntryPath(path: string): string {
return path
.replace(/\\/g, '/')
.replace(/^\/+/, '')
.split('/')
.filter((part) => part && part !== '.')
.join('/');
}
function isSafeZipEntryPath(path: string): boolean {
return !!path && !path.split('/').includes('..');
}
/**
* 为 sandbox 解压归一化 skill ZIP 路径。
*
* 有些包会存成 `skill-name/SKILL.md` 或 `archive-root/skill-name/SKILL.md`。
* sandbox 已经会给每个选中 skill 创建目标目录,如果保留单包根目录,会得到
* `skills/<target>/skill-name/SKILL.md` 这种多一层的结构。
*
* 单 SKILL.md 包会剥掉 SKILL.md 所在目录作为 sandbox 根;多 skill 包如果只有一个公共外壳目录,
* 只剥掉公共外壳,保留内部每个 skill 目录。
*/
export async function normalizeSkillPackageZipForSandbox(zipBuffer: Buffer): Promise<Buffer> {
const zip = await JSZip.loadAsync(zipBuffer);
const entries = Object.entries(zip.files)
.filter(([, file]) => !file.dir)
.map(([path, file]) => ({
path: normalizeZipEntryPath(path),
file
}))
.filter(({ path }) => isSafeZipEntryPath(path));
const skillMdEntries = entries.filter((entry) => {
const filename = entry.path.split('/').pop()?.toLowerCase();
return filename === 'skill.md';
});
// 1. 检查是否有任何文件以 'skills/' 开头
const hasSkillsDirectory = entries.some((entry) => entry.path.startsWith('skills/'));
const rootPrefix = (() => {
if (hasSkillsDirectory) {
return 'skills/';
}
// 2. 如果没有 'skills/' 目录,说明是非标准旧包,退回到降维兼容逻辑
if (skillMdEntries.length === 1 && skillMdEntries[0].path.includes('/')) {
const path = skillMdEntries[0].path;
const lastSlashIdx = path.lastIndexOf('/');
if (lastSlashIdx !== -1) {
const secondLastSlashIdx = path.lastIndexOf('/', lastSlashIdx - 1);
if (secondLastSlashIdx !== -1) {
return path.slice(0, secondLastSlashIdx + 1);
}
}
return '';
}
const topLevelNames = new Set<string>();
let hasRootFile = false;
for (const { path } of entries) {
const slashIndex = path.indexOf('/');
if (slashIndex === -1) {
hasRootFile = true;
} else {
topLevelNames.add(path.slice(0, slashIndex));
}
}
return skillMdEntries.length > 1 && !hasRootFile && topLevelNames.size === 1
? `${Array.from(topLevelNames)[0]}/`
: '';
})();
const normalizedZip = new JSZip();
await Promise.all(
entries.map(async ({ path, file }) => {
// 3. 如果是有 'skills/' 文件夹的标准包,我们只解压以 'skills/' 开头的文件,忽略不相干的文件
if (hasSkillsDirectory && !path.startsWith('skills/')) {
return;
}
const targetPath =
rootPrefix && path.startsWith(rootPrefix) ? path.slice(rootPrefix.length) : path;
if (!targetPath) return;
normalizedZip.file(targetPath, await file.async('nodebuffer'));
})
);
return generateZipBuffer(normalizedZip);
}
/**
* 将 skill ZIP 归一化后展开成文件写入清单。
*
* 运行态不再依赖 sandbox 内的 `unzip` 命令处理文件名,避免中文路径在不同
* unzip/locale 组合下被错误解码。
*/
export async function extractNormalizedSkillPackageFilesForSandbox(
zipBuffer: Buffer
): Promise<NormalizedSkillPackageFile[]> {
const normalizedBuffer = await normalizeSkillPackageZipForSandbox(zipBuffer);
const zip = await JSZip.loadAsync(normalizedBuffer);
const entries = Object.entries(zip.files)
.filter(([, file]) => !file.dir)
.map(([path, file]) => ({
path: normalizeZipEntryPath(path),
file
}))
.filter(({ path }) => isSafeZipEntryPath(path));
return Promise.all(
entries.map(async ({ path, file }) => ({
path,
data: await file.async('nodebuffer')
}))
);
}
/**
* 获取 ZIP 内文件列表,读取失败时返回空数组供调试接口容错展示。
*/
export async function getZipFileList(zipBuffer: Buffer): Promise<string[]> {
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { MongoAgentSkills } from '../model/schema';
import { MongoAgentSkillsVersion } from '../version/schema';
import { downloadSkillPackage, extractNormalizedSkillPackageFilesForSandbox } from '../package';
import { parseSkillMarkdown } from '../utils/skillMarkdown';
import { downloadSkillPackage } from '../package';
import {
parseSkillMarkdown,
shellQuote,
joinSandboxPath,
getSkillsRootPath,
getSafeSkillDirectoryName,
getSkillTargetPath
} from '../utils';
import { getLogger, LogCategories } from '../../../../common/logger';
import type { DeployedSkillInfo } from './types';
export type { DeployedSkillInfo } from './types';
const logger = getLogger(LogCategories.MODULE.AI.AGENT);
const MAX_SKILL_DIRECTORY_NAME_LENGTH = 50;
export const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`;
const trimSandboxPathRight = (value: string) => (value === '/' ? '' : value.replace(/\/+$/, ''));
export const joinSandboxPath = (basePath: string, path: string) =>
`${trimSandboxPathRight(basePath)}/${path}`;
const getSandboxParentPath = (path: string) => {
const normalizedPath = path.replace(/\/+$/, '');
const slashIndex = normalizedPath.lastIndexOf('/');
return slashIndex > 0 ? normalizedPath.slice(0, slashIndex) : '/';
};
export const getSkillsRootPath = (workDirectory: string) =>
joinSandboxPath(workDirectory, 'skills');
export const getSafeSkillDirectoryName = (skillName: string) => {
const normalized = skillName
.trim()
// 1. 将空格和空白字符替换为中划线
.replace(/\s+/g, '-')
// 2. 只保留中文、英文、数字、中划线和下划线,其它所有非法/危险字符都替换为中划线
.replace(/[^\w\u4e00-\u9fa5-]/g, '-')
// 3. 将连续的多个中划线或下划线合并为单个
.replace(/-+/g, '-')
.replace(/_+/g, '_')
// 4. 去除首尾的多余中划线/下划线
.replace(/^[-_]|[-_]$/g, '')
// 5. 限制长度在合理范围
.slice(0, MAX_SKILL_DIRECTORY_NAME_LENGTH)
.trim();
// 6. 排除特殊目录名或为空、纯中/下划线的情况,使用安全回退值
return normalized && normalized !== '.' && normalized !== '..' && !/^[-_]+$/.test(normalized)
? normalized
: 'skill';
};
export const getSkillTargetPath = ({
workDirectory,
skillId
}: {
workDirectory: string;
skillId: string;
}) => joinSandboxPath(getSkillsRootPath(workDirectory), getSafeSkillDirectoryName(skillId));
export const getEditSkillTargetPath = ({
workDirectory,
skillName,
skillId
}: {
workDirectory: string;
skillName: string;
skillId: string;
}) =>
// 编辑态没有 version 语义,固定目录可让代码编辑器和保存发布读取同一份文件。
`${getSkillsRootPath(workDirectory)}/${getSafeSkillDirectoryName(skillName)}-${skillId}-edit`;
const parseCommandOutputLines = (stdout: string) => stdout.trim().split('\n').filter(Boolean);
type GetAgentSkillInfosParams = {
......@@ -224,39 +181,35 @@ export const injectAgentSkillFilesToSandbox = async ({
throw new Error(`Failed to reset skill directory: ${resetSkillsRootResult.stderr}`);
}
await Promise.all(
const parentDirs = deployableSkills.map(({ targetDir }) => targetDir);
const mkdirResult = await sandbox.execute(
`mkdir -p ${parentDirs.map((dir) => shellQuote(dir)).join(' ')}`
);
if (mkdirResult.exitCode !== 0) {
throw new Error(`Failed to create skill directories inside sandbox: ${mkdirResult.stderr}`);
}
const results = await Promise.all(
deployableSkills.map(async ({ skill, version, targetDir }) => {
try {
const rawPackageBuffer = await downloadSkillPackage({ storageKey: version.storageKey });
const packageFiles = await extractNormalizedSkillPackageFilesForSandbox(rawPackageBuffer);
const writeEntries = packageFiles.map((file) => ({
path: joinSandboxPath(targetDir, file.path),
data: file.data
}));
const parentDirs = Array.from(
new Set([targetDir, ...writeEntries.map((entry) => getSandboxParentPath(entry.path))])
);
const mkdirResult = await sandbox.execute(
`mkdir -p ${parentDirs.map((dir) => shellQuote(dir)).join(' ')}`
);
if (mkdirResult.exitCode !== 0) {
await sandbox.execute(`rm -rf ${shellQuote(targetDir)}`);
throw new Error(
`Failed to prepare skill package directory "${skill.name}": ${mkdirResult.stderr}`
);
}
const writeResults = await sandbox.writeFiles(writeEntries);
const failedWrite = writeResults.find((result) => result.error);
if (failedWrite) {
await sandbox.execute(`rm -rf ${shellQuote(targetDir)}`);
throw new Error(
`Failed to write skill package "${skill.name}" file ${failedWrite.path}: ${failedWrite.error?.message}`
);
}
const zipPath = joinSandboxPath(targetDir, 'package.zip');
const quotedTargetDir = shellQuote(targetDir);
const unzipCommand = `(${[
`cd ${quotedTargetDir}`,
`unzip -o -q package.zip`,
`rm -f package.zip`
].join(' && ')})`;
return {
writeEntry: {
path: zipPath,
data: rawPackageBuffer
},
unzipCommand
};
} catch (error) {
logger.error('[Agent Skills] Failed to inject skill package', {
logger.error('[Agent Skills] Failed to prepare skill package', {
skillName: skill.name,
error
});
......@@ -265,6 +218,33 @@ export const injectAgentSkillFilesToSandbox = async ({
})
);
const writeEntries = results.map((r) => r.writeEntry);
const unzipCommands = results.map((r) => r.unzipCommand);
// 1. Batch write all ZIP packages directly to their respective folders in a single call
const writeResults = await sandbox.writeFiles(writeEntries);
const failedWrite = writeResults.find((result) => result.error);
if (failedWrite) {
await Promise.all(
deployableSkills.map(({ targetDir }) =>
sandbox.execute(`rm -rf ${shellQuote(targetDir)}`).catch(() => {})
)
);
throw new Error(`Failed to write skill ZIP packages: ${failedWrite.error?.message}`);
}
// 2. Execute a single unified decompression command inside the sandbox container
const finalUnzipCmd = unzipCommands.join(' && ');
const extractResult = await sandbox.execute(finalUnzipCmd);
if (extractResult.exitCode !== 0) {
await Promise.all(
deployableSkills.map(({ targetDir }) =>
sandbox.execute(`rm -rf ${shellQuote(targetDir)}`).catch(() => {})
)
);
throw new Error(`Failed to decompress skill packages inside sandbox: ${extractResult.stderr}`);
}
return getAgentSkillInfos({
sandbox,
// 只扫描本轮部署出来的 skill 目录,避免工作区其他 SKILL.md 被误注入 prompt。
......
/**
* Skill 纯工具统一出口。
*
* 这里只放无副作用的 SKILL.md 文本解析和模板拼装,不访问数据库、对象存储、sandbox 或 LLM。
*/
export { parseSkillMarkdown } from './skillMarkdown';
export * from './skillMdTemplate';
/**
* 解析 SKILL.md 的 YAML frontmatter,并返回 frontmatter 与正文内容。
*
* FastGPT 当前只依赖简单的 frontmatter 字段,因此这里使用轻量解析器处理常见
* key/value、数组和一层对象。复杂 YAML 语法如果解析失败,会通过 error 返回给调用方。
*/
export function parseSkillMarkdown(markdown: string): {
frontmatter: Record<string, any>;
content: string;
error?: string;
} {
const frontmatterRegex = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?$/;
const match = markdown.match(frontmatterRegex);
if (!match) {
return {
frontmatter: {},
content: markdown,
error: 'SKILL.md must contain YAML frontmatter (delimited by ---)'
};
}
const yamlContent = match[1];
const bodyContent = match[2] ?? '';
try {
const frontmatter = parseYamlFrontmatter(yamlContent);
return {
frontmatter,
content: bodyContent
};
} catch (error: any) {
return {
frontmatter: {},
content: markdown,
error: `Failed to parse frontmatter: ${error.message}`
};
}
}
/**
* 轻量 frontmatter YAML 解析器。
*
* 这里只覆盖 SKILL.md 元数据当前需要的简单结构:`key: value`、布尔值、数字、
* null、行内数组,以及 `metadata:` 这种对象字段。后续如果要支持完整 YAML,
* 应该在这个文件里统一替换实现,避免解析规则分散在业务流程里。
*/
function parseYamlFrontmatter(yaml: string): Record<string, any> {
const result: Record<string, any> = {};
const lines = yaml.split('\n');
let currentObj = result;
const stack: { key: string; obj: Record<string, any> }[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (trimmed.endsWith(':')) {
const key = trimmed.slice(0, -1).trim();
currentObj[key] = {};
stack.push({ key, obj: currentObj });
currentObj = currentObj[key] as Record<string, any>;
continue;
}
const colonIndex = line.indexOf(':');
if (colonIndex === -1) continue;
const key = line.slice(0, colonIndex).trim();
const value = line.slice(colonIndex + 1).trim();
if (value.startsWith('"') || value.startsWith("'")) {
currentObj[key] = value.slice(1, -1);
} else if (value === 'true') {
currentObj[key] = true;
} else if (value === 'false') {
currentObj[key] = false;
} else if (!isNaN(Number(value)) && value !== '') {
currentObj[key] = Number(value);
} else if (value === 'null') {
currentObj[key] = null;
} else if (value.startsWith('[') && value.endsWith(']')) {
const arrayContent = value.slice(1, -1).trim();
currentObj[key] = arrayContent
? arrayContent.split(',').map((item) => item.trim().replace(/["']/g, ''))
: [];
} else {
currentObj[key] = value;
}
}
return result;
}
export type BuildSkillMdParams = {
name: string;
description: string;
};
/**
* 生成一个最小可用的 SKILL.md。
*
* 该模板只包含 frontmatter,不生成正文说明,主要用于没有 AI 辅助生成需求的
* 初次创建流程。后续用户可在 edit sandbox 中继续补充正文和其他文件。
*/
export function buildSkillMd(params: BuildSkillMdParams): string {
return generateFrontmatter(params.name, params.description);
}
/**
* 生成 SKILL.md 所需的 YAML frontmatter。
*
* 当前只写入 name 和 description,保持创建阶段的默认包尽量轻量。
*/
export function generateFrontmatter(name: string, description: string): string {
const escapedName = escapeYaml(name);
const escapedDescription = escapeYaml(description);
return `---\nname: ${escapedName}\ndescription: ${escapedDescription}\n---`;
}
/**
* 解析由本模板工具生成或兼容的简单 frontmatter。
*
* 这里只服务模板构造相关的轻量读取;导入/部署时的正式 SKILL.md 元数据解析
* 仍应使用 `utils/skillMarkdown.ts`,避免业务校验规则分散。
*/
export function parseFrontmatter(content: string): {
name: string;
description: string;
body: string;
} {
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/);
if (!frontmatterMatch) {
throw new Error('Invalid SKILL.md format: missing frontmatter');
}
const frontmatterText = frontmatterMatch[1];
const body = frontmatterMatch[2];
const nameMatch = frontmatterText.match(/^name:\s*(.+)$/m);
const name = nameMatch ? unescapeYaml(nameMatch[1].trim()) : '';
const descMatch = frontmatterText.match(/^description:\s*(.+)$/m);
const description = descMatch ? unescapeYaml(descMatch[1].trim()) : '';
return { name, description, body };
}
/**
* 将字符串转成适合写入简单 YAML 标量的形式。
*/
export function escapeYaml(value: string): string {
if (value === '') {
return '""';
}
const needsQuoting =
/[:#{}\[\],&*?|<>!=~`@]/.test(value) ||
/^[-?]/.test(value) ||
value.includes('\n') ||
value.includes('"') ||
/^true$|^false$|^null$|^~$/i.test(value);
if (!needsQuoting) {
return value;
}
if (value.includes('\n')) {
const lines = value.split('\n');
return '|\n' + lines.map((line) => ' ' + line).join('\n');
}
const escaped = value.replace(/"/g, '\\"');
return `"${escaped}"`;
}
/**
* 反解析 `escapeYaml` 支持的简单 quoted scalar。
*/
export function unescapeYaml(value: string): string {
if (value.startsWith('"') && value.endsWith('"')) {
return value.slice(1, -1).replace(/\\"/g, '"');
}
if (value.startsWith("'") && value.endsWith("'")) {
return value.slice(1, -1).replace(/\\'/g, "'");
}
return value;
}
/**
* 校验 skill name 是否满足 Agent Skills 的 kebab-case 约束。
*/
export function validateSkillName(name: string): boolean {
if (name.length === 0 || name.length > 64) {
return false;
}
if (!/^[a-z0-9-]+$/.test(name)) {
return false;
}
if (name.startsWith('-') || name.endsWith('-')) {
return false;
}
if (name.includes('--')) {
return false;
}
return true;
}
/**
* 将用户输入清洗成可用于 skill 包目录名的 kebab-case-ish 字符串。
*/
export function sanitizeSkillNameForFile(name: string): string {
return name
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/_/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 64);
}
/**
* 从 SKILL.md 内容里提取 name,缺少 frontmatter 时回退到首个一级标题。
*/
export function extractSkillNameFromSkillMd(content: string): string {
try {
const { name } = parseFrontmatter(content);
return name;
} catch {
const headerMatch = content.match(/^#\s+(.+)$/m);
return headerMatch ? sanitizeSkillNameForFile(headerMatch[1]) : 'unnamed-skill';
}
}
/**
* 从 SKILL.md 内容里提取 description,解析失败时返回空字符串。
*/
export function extractDescriptionFromSkillMd(content: string): string {
try {
const { description } = parseFrontmatter(content);
return description;
} catch {
return '';
}
}
......@@ -7,4 +7,3 @@
export * from './types';
export * from './create';
export * from './query';
export * from './update';
......@@ -55,10 +55,3 @@ export async function listVersions(
return versions as AgentSkillsVersionSchemaType[];
}
/**
* Count versions for a skill.
*/
export async function countVersions(skillId: string): Promise<number> {
return MongoAgentSkillsVersion.countDocuments({ skillId });
}
import { MongoAgentSkillsVersion } from './schema';
import type { ClientSession } from '../../../../common/mongo';
/**
* Update version metadata.
*/
export async function updateVersion(
skillId: string,
versionId: string,
data: Partial<{
versionName: string;
}>,
session?: ClientSession
): Promise<void> {
const result = await MongoAgentSkillsVersion.updateOne(
{ _id: versionId, skillId },
{ $set: data },
{ session }
);
if (result.matchedCount === 0) {
throw new Error(`Version ${versionId} not found for skill ${skillId}`);
}
}
......@@ -11,7 +11,7 @@ import {
importSkill
} from '@fastgpt/service/core/ai/skill/manage';
import { MongoAgentSkillsVersion } from '@fastgpt/service/core/ai/skill/version/schema';
import { parseSkillMarkdown } from '@fastgpt/service/core/ai/skill/utils/skillMarkdown';
import { parseSkillMarkdown } from '@fastgpt/service/core/ai/skill/utils';
import {
AgentSkillSourceEnum,
AgentSkillCategoryEnum
......
......@@ -24,7 +24,7 @@ vi.mock('@fastgpt/service/core/ai/skill/version/schema', () => ({
vi.mock('@fastgpt/service/core/ai/skill/package', () => ({
downloadSkillPackage: vi.fn(),
extractNormalizedSkillPackageFilesForSandbox: vi.fn()
DEFAULT_GITIGNORE_CONTENT: '# mock gitignore'
}));
vi.mock('@fastgpt/service/core/ai/skill/sandbox/config', () => ({
......@@ -92,7 +92,8 @@ vi.mock('@fastgpt/service/core/ai/skill/runtime', () => {
getSkillsRootPath: (workDirectory: string) => `${trimSandboxPathRight(workDirectory)}/skills`,
joinSandboxPath: (basePath: string, path: string) =>
`${trimSandboxPathRight(basePath)}/${path}`,
shellQuote: (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
shellQuote: (value: string) => `'${value.replace(/'/g, `'\\''`)}'`,
getSafeSkillDirectoryName: (name: string) => name
};
});
......@@ -117,10 +118,7 @@ vi.mock('@fastgpt/service/support/permission/teamLimit', () => ({
import { MongoAgentSkills } from '@fastgpt/service/core/ai/skill/model/schema';
import { MongoAgentSkillsVersion } from '@fastgpt/service/core/ai/skill/version/schema';
import {
downloadSkillPackage,
extractNormalizedSkillPackageFilesForSandbox
} from '@fastgpt/service/core/ai/skill/package';
import { downloadSkillPackage } from '@fastgpt/service/core/ai/skill/package';
import {
createEditDebugSandbox,
packageSkillInSandbox
......@@ -146,7 +144,10 @@ const createSandbox = ({ readFilesResult }: { readFilesResult: MockReadFileResul
if (command.startsWith('[ -d ')) {
return { exitCode: 0, stdout: '', stderr: '' };
}
if (command.startsWith('find ')) {
if (command.includes(" -name '.gitignore'")) {
return { exitCode: 0, stdout: '', stderr: '' };
}
if (command.includes('find . ') && command.includes('-prune')) {
return { exitCode: 0, stdout: '12', stderr: '' };
}
if (command.startsWith('cd ')) {
......@@ -157,7 +158,12 @@ const createSandbox = ({ readFilesResult }: { readFilesResult: MockReadFileResul
}
return { exitCode: 0, stdout: '', stderr: '' };
}),
readFiles: vi.fn(async () => readFilesResult)
readFiles: vi.fn(async (paths: string[]) => {
if (paths.includes('/workspace/package.zip')) {
return readFilesResult;
}
return [];
})
};
return sandbox;
......@@ -209,6 +215,60 @@ describe('packageSkillInSandbox', () => {
expect(sandbox.execute).toHaveBeenCalledWith("rm -f '/workspace/package.zip'");
expect(mocks.disconnectSandbox).toHaveBeenCalledWith(sandbox);
});
it('reads and parses custom .gitignore files from the sandbox correctly', async () => {
const gitignoreContent = `
# ignore node and env
my_custom_ignored_dir/
temp_data.csv
`;
const zipContent = new Uint8Array([9, 8, 7]);
const sandbox = {
execute: vi.fn(async (command: string) => {
if (command.startsWith('[ -d ')) {
return { exitCode: 0, stdout: '', stderr: '' };
}
if (command.includes(" -name '.gitignore'")) {
return { exitCode: 0, stdout: '/workspace/.gitignore\n', stderr: '' };
}
if (command.includes('find . ') && command.includes('-prune')) {
return { exitCode: 0, stdout: '100', stderr: '' };
}
if (command.startsWith('cd ')) {
return { exitCode: 0, stdout: 'zip ok', stderr: '' };
}
if (command.startsWith('rm -f ')) {
return { exitCode: 0, stdout: '', stderr: '' };
}
return { exitCode: 0, stdout: '', stderr: '' };
}),
readFiles: vi.fn(async (paths: string[]) => {
if (paths.includes('/workspace/.gitignore')) {
return [{ path: '/workspace/.gitignore', content: gitignoreContent, error: null }];
}
if (paths.includes('/workspace/package.zip')) {
return [{ path: '/workspace/package.zip', content: zipContent, error: null }];
}
return [];
})
};
mocks.connectToSandbox.mockResolvedValueOnce(sandbox);
await expect(packageSkillInSandbox({ sandboxId: 'sandbox-1' })).resolves.toEqual(
Buffer.from(zipContent)
);
// Verify .gitignore was read
expect(sandbox.readFiles).toHaveBeenCalledWith(['/workspace/.gitignore']);
// Verify zip was called with custom excludes
expect(sandbox.execute).toHaveBeenCalledWith(
expect.stringContaining("-x 'my_custom_ignored_dir/*'")
);
expect(sandbox.execute).toHaveBeenCalledWith(
expect.stringContaining("-x '*/my_custom_ignored_dir/*'")
);
expect(sandbox.execute).toHaveBeenCalledWith(expect.stringContaining("-x 'temp_data.csv'"));
});
});
describe('createEditDebugSandbox', () => {
......@@ -216,7 +276,7 @@ describe('createEditDebugSandbox', () => {
vi.clearAllMocks();
});
it('writes extracted files directly so Chinese skill directory names are preserved', async () => {
it('uploads zip packages and decompresses inside the sandbox so Chinese skill directory names are preserved', async () => {
const packageBuffer = Buffer.from('zip');
const skillId = 'skill-1';
const provider = {
......@@ -225,10 +285,10 @@ describe('createEditDebugSandbox', () => {
if (command === "mkdir -p '/workspace'") {
return { exitCode: 0, stdout: '', stderr: '' };
}
if (
command ===
"rm -rf '/workspace/skills' && mkdir -p '/workspace/skills' '/workspace/skills/测试的'"
) {
if (command === "rm -rf '/workspace/skills' && mkdir -p '/workspace/skills'") {
return { exitCode: 0, stdout: '', stderr: '' };
}
if (command.includes('unzip')) {
return { exitCode: 0, stdout: '', stderr: '' };
}
return { exitCode: 1, stdout: '', stderr: `Unexpected command: ${command}` };
......@@ -255,12 +315,6 @@ describe('createEditDebugSandbox', () => {
vi.mocked(findSandboxResourcesByAppChatTypeExcludeProvider).mockResolvedValueOnce([]);
vi.mocked(countRunningSandboxInstancesByType).mockResolvedValueOnce(0);
vi.mocked(downloadSkillPackage).mockResolvedValueOnce(packageBuffer);
vi.mocked(extractNormalizedSkillPackageFilesForSandbox).mockResolvedValueOnce([
{
path: '测试的/SKILL.md',
data: Buffer.from('---\nname: 测试的\n---')
}
]);
vi.mocked(getSandboxClient).mockResolvedValueOnce({
provider,
delete: vi.fn()
......@@ -287,11 +341,11 @@ describe('createEditDebugSandbox', () => {
expect(provider.writeFiles).toHaveBeenCalledWith([
{
path: '/workspace/skills/测试的/SKILL.md',
data: Buffer.from('---\nname: 测试的\n---')
path: '/workspace/skills/package.zip',
data: packageBuffer
}
]);
expect(provider.execute).not.toHaveBeenCalledWith(expect.stringContaining('unzip'));
expect(provider.execute).toHaveBeenCalledWith(expect.stringContaining('unzip'));
expect(mocks.disconnectSandbox).toHaveBeenCalledWith(provider);
});
});
import { describe, expect, it } from 'vitest';
import {
getSupportedArchiveFormat,
findSkillMdKey,
getRootPrefix,
stripRootPrefix,
isIgnoredSystemArchiveEntry,
normalizeSkillWorkspaceRoot,
hasSkillsDirectoryContent,
repackFileMapAsZip,
JSZip
} from '@fastgpt/service/core/ai/skill/package';
// ===========================================================================
// getSupportedArchiveFormat
// ===========================================================================
describe('getSupportedArchiveFormat', () => {
it('returns zip for .zip', () => {
expect(getSupportedArchiveFormat('skill.zip')).toBe('zip');
});
it('returns tar for .tar', () => {
expect(getSupportedArchiveFormat('skill.tar')).toBe('tar');
});
it('returns tar.gz for .tar.gz', () => {
expect(getSupportedArchiveFormat('skill.tar.gz')).toBe('tar.gz');
});
it('returns tar.gz for .tgz', () => {
expect(getSupportedArchiveFormat('skill.tgz')).toBe('tar.gz');
});
it('returns null for unsupported extension', () => {
expect(getSupportedArchiveFormat('skill.rar')).toBeNull();
expect(getSupportedArchiveFormat('skill.7z')).toBeNull();
expect(getSupportedArchiveFormat('skill')).toBeNull();
});
it('is case-insensitive', () => {
expect(getSupportedArchiveFormat('Skill.ZIP')).toBe('zip');
expect(getSupportedArchiveFormat('Skill.TAR.GZ')).toBe('tar.gz');
expect(getSupportedArchiveFormat('Skill.TGZ')).toBe('tar.gz');
});
});
// ===========================================================================
// isIgnoredSystemArchiveEntry
// ===========================================================================
describe('isIgnoredSystemArchiveEntry', () => {
it('ignores common OS generated archive entries', () => {
expect(isIgnoredSystemArchiveEntry('__MACOSX/._SKILL.md')).toBe(true);
expect(isIgnoredSystemArchiveEntry('workspace/__MACOSX/skills/._main.ts')).toBe(true);
expect(isIgnoredSystemArchiveEntry('skills/demo/.DS_Store')).toBe(true);
expect(isIgnoredSystemArchiveEntry('skills/demo/._SKILL.md')).toBe(true);
expect(isIgnoredSystemArchiveEntry('skills/demo/Thumbs.db')).toBe(true);
expect(isIgnoredSystemArchiveEntry('skills/demo/desktop.ini')).toBe(true);
});
it('keeps normal skill files', () => {
expect(isIgnoredSystemArchiveEntry('skills/demo/SKILL.md')).toBe(false);
expect(isIgnoredSystemArchiveEntry('skills/demo/src/main.ts')).toBe(false);
expect(isIgnoredSystemArchiveEntry('skills/demo/.env.example')).toBe(false);
});
});
// ===========================================================================
// findSkillMdKey
// ===========================================================================
describe('findSkillMdKey', () => {
it('finds SKILL.md at root', () => {
const fileMap = { 'SKILL.md': Buffer.from(''), 'main.py': Buffer.from('') };
expect(findSkillMdKey(fileMap)).toBe('SKILL.md');
});
it('finds skill.md at root case-insensitively', () => {
const fileMap = { 'skill.md': Buffer.from(''), 'main.py': Buffer.from('') };
expect(findSkillMdKey(fileMap)).toBe('skill.md');
});
it('finds SKILL.md one level deep', () => {
const fileMap = { 'my-skill/SKILL.md': Buffer.from(''), 'my-skill/main.py': Buffer.from('') };
expect(findSkillMdKey(fileMap)).toBe('my-skill/SKILL.md');
});
it('does not find SKILL.md more than one level deep', () => {
const fileMap = { 'a/b/SKILL.md': Buffer.from('') };
expect(findSkillMdKey(fileMap)).toBeNull();
});
it('returns null when no SKILL.md present', () => {
const fileMap = { 'README.md': Buffer.from(''), 'main.py': Buffer.from('') };
expect(findSkillMdKey(fileMap)).toBeNull();
});
it('returns null for empty file map', () => {
expect(findSkillMdKey({})).toBeNull();
});
});
// ===========================================================================
// getRootPrefix
// ===========================================================================
describe('getRootPrefix', () => {
it('returns empty string for root-level SKILL.md', () => {
expect(getRootPrefix('SKILL.md')).toBe('');
});
it('returns directory prefix for subdirectory SKILL.md', () => {
expect(getRootPrefix('my-skill/SKILL.md')).toBe('my-skill/');
});
});
// ===========================================================================
// stripRootPrefix
// ===========================================================================
describe('stripRootPrefix', () => {
it('returns unchanged map when prefix is empty', () => {
const fileMap = { 'SKILL.md': Buffer.from('a'), 'main.py': Buffer.from('b') };
expect(stripRootPrefix(fileMap, '')).toEqual(fileMap);
});
it('strips the prefix from matching keys', () => {
const fileMap = {
'my-skill/SKILL.md': Buffer.from('a'),
'my-skill/src/main.py': Buffer.from('b'),
'other/file.txt': Buffer.from('c') // does not match prefix
};
const result = stripRootPrefix(fileMap, 'my-skill/');
expect(result).toHaveProperty('SKILL.md');
expect(result).toHaveProperty('src/main.py');
expect(result).toHaveProperty('other/file.txt');
expect(result).not.toHaveProperty('my-skill/SKILL.md');
});
it('removes keys that become empty after stripping', () => {
// Key equals the prefix itself — stripping yields '' which should be dropped
const fileMap = { 'my-skill/': Buffer.from('') };
const result = stripRootPrefix(fileMap, 'my-skill/');
expect(Object.keys(result)).toHaveLength(0);
});
});
// ===========================================================================
// normalizeSkillWorkspaceRoot / hasSkillsDirectoryContent
// ===========================================================================
describe('normalizeSkillWorkspaceRoot', () => {
it('keeps workspace-root skills directory unchanged', () => {
const fileMap = {
'skills/my-skill/SKILL.md': Buffer.from('a'),
'skills/my-skill/src/main.ts': Buffer.from('b')
};
expect(normalizeSkillWorkspaceRoot(fileMap)).toEqual(fileMap);
});
it('strips archive wrapper before skills directory', () => {
const fileMap = {
'exported-workspace/skills/my-skill/SKILL.md': Buffer.from('a'),
'exported-workspace/skills/my-skill/src/main.ts': Buffer.from('b')
};
const result = normalizeSkillWorkspaceRoot(fileMap);
expect(result).toHaveProperty('skills/my-skill/SKILL.md');
expect(result).toHaveProperty('skills/my-skill/src/main.ts');
expect(result).not.toHaveProperty('exported-workspace/skills/my-skill/SKILL.md');
});
it('returns unchanged map when no skills directory exists', () => {
const fileMap = { 'my-skill/SKILL.md': Buffer.from('a') };
expect(normalizeSkillWorkspaceRoot(fileMap)).toEqual(fileMap);
});
});
describe('hasSkillsDirectoryContent', () => {
it('returns true when skills directory has files', () => {
expect(
hasSkillsDirectoryContent({
'skills/my-skill/SKILL.md': Buffer.from('a')
})
).toBe(true);
});
it('returns false for legacy root SKILL.md packages', () => {
expect(
hasSkillsDirectoryContent({
'SKILL.md': Buffer.from('a'),
'src/main.ts': Buffer.from('b')
})
).toBe(false);
});
it('returns false when only non-skills files exist', () => {
expect(
hasSkillsDirectoryContent({
'README.md': Buffer.from('a')
})
).toBe(false);
});
});
// ===========================================================================
// repackFileMapAsZip
// ===========================================================================
describe('repackFileMapAsZip', () => {
it('creates a valid ZIP buffer from a file map', async () => {
const fileMap = {
'SKILL.md': Buffer.from('# skill'),
'src/main.py': Buffer.from('print("hello")')
};
const zipBuffer = await repackFileMapAsZip(fileMap);
expect(zipBuffer).toBeInstanceOf(Buffer);
expect(zipBuffer.length).toBeGreaterThan(0);
});
it('preserves all file paths in the resulting ZIP', async () => {
const fileMap = {
'SKILL.md': Buffer.from('# skill'),
'src/utils.py': Buffer.from(''),
'data/config.json': Buffer.from('{}')
};
const zipBuffer = await repackFileMapAsZip(fileMap);
const zip = await JSZip.loadAsync(zipBuffer);
const files = Object.keys(zip.files).filter((k) => !zip.files[k].dir);
expect(files).toContain('SKILL.md');
expect(files).toContain('src/utils.py');
expect(files).toContain('data/config.json');
});
it('preserves file content', async () => {
const content = 'name: test\ndescription: hello';
const fileMap = { 'SKILL.md': Buffer.from(content) };
const zipBuffer = await repackFileMapAsZip(fileMap);
const zip = await JSZip.loadAsync(zipBuffer);
const extracted = await zip.file('SKILL.md')!.async('string');
expect(extracted).toBe(content);
});
it('returns empty (but valid) ZIP for empty file map', async () => {
const zipBuffer = await repackFileMapAsZip({});
expect(zipBuffer).toBeInstanceOf(Buffer);
const zip = await JSZip.loadAsync(zipBuffer);
expect(Object.keys(zip.files)).toHaveLength(0);
});
});
import { describe, expect, it } from 'vitest';
import {
validateSkillPackage,
parseSkillPackage,
sanitizeSkillName,
createSkillTemplate
} from '@fastgpt/service/core/ai/skill/package';
import { AgentSkillCategoryEnum } from '@fastgpt/global/core/ai/skill/constants';
describe('AgentSkill package metadata', () => {
// ==================== validateSkillPackage ====================
describe('validateSkillPackage', () => {
it('should validate valid package', () => {
const packageData = {
skill: {
name: 'Test Skill',
description: 'A test skill',
category: [AgentSkillCategoryEnum.tool]
},
markdown: '# Test Skill\n\nDescription'
};
const result = validateSkillPackage(packageData);
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it('should reject invalid package format', () => {
const result = validateSkillPackage(null);
expect(result.valid).toBe(false);
expect(result.error).toBe('Invalid package format');
});
it('should reject missing skill metadata', () => {
const result = validateSkillPackage({ markdown: '# Test' });
expect(result.valid).toBe(false);
expect(result.error).toBe('Missing skill metadata');
});
it('should reject missing skill name', () => {
const result = validateSkillPackage({
skill: { description: 'A skill' },
markdown: '# Test'
});
expect(result.valid).toBe(false);
expect(result.error).toBe('Skill name is required');
});
it('should reject empty skill name', () => {
const result = validateSkillPackage({
skill: { name: ' ' },
markdown: '# Test'
});
expect(result.valid).toBe(false);
expect(result.error).toBe('Skill name is required');
});
it('should reject skill name exceeding 50 characters', () => {
const result = validateSkillPackage({
skill: { name: 'a'.repeat(51) },
markdown: '# Test'
});
expect(result.valid).toBe(false);
expect(result.error).toBe('Skill name must be less than 50 characters');
});
it('should accept package without markdown field', () => {
// markdown is not part of SkillPackageType, so it's not validated
const result = validateSkillPackage({
skill: { name: 'Test Skill' }
});
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it('should accept package with any extra fields', () => {
// validateSkillPackage only checks skill.* fields, not additional fields
const result = validateSkillPackage({
skill: { name: 'Test Skill' },
extraField: 'anything'
});
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it('should reject description exceeding 500 characters', () => {
const result = validateSkillPackage({
skill: {
name: 'Test Skill',
description: 'a'.repeat(501)
},
markdown: '# Test'
});
expect(result.valid).toBe(false);
expect(result.error).toBe('Description must be less than 500 characters');
});
it('should reject non-array category', () => {
const result = validateSkillPackage({
skill: {
name: 'Test Skill',
category: 'tool'
},
markdown: '# Test'
});
expect(result.valid).toBe(false);
expect(result.error).toBe('Category must be an array');
});
it('should reject invalid categories', () => {
const result = validateSkillPackage({
skill: {
name: 'Test Skill',
category: ['invalid_category', 'tool']
},
markdown: '# Test'
});
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid categories');
expect(result.error).toContain('invalid_category');
});
it('should accept valid categories', () => {
const result = validateSkillPackage({
skill: {
name: 'Test Skill',
category: [
AgentSkillCategoryEnum.tool,
AgentSkillCategoryEnum.search,
AgentSkillCategoryEnum.coding
]
},
markdown: '# Test'
});
expect(result.valid).toBe(true);
});
});
// ==================== parseSkillPackage ====================
describe('parseSkillPackage', () => {
it('should parse valid JSON string', () => {
const packageData = {
skill: {
name: 'Test Skill',
description: 'A test skill',
category: []
},
markdown: '# Test'
};
const result = parseSkillPackage(JSON.stringify(packageData));
expect(result.success).toBe(true);
expect(result.package).toEqual(packageData);
});
it('should parse valid object', () => {
const packageData = {
skill: {
name: 'Test Skill',
description: 'A test skill',
category: []
},
markdown: '# Test'
};
const result = parseSkillPackage(packageData);
expect(result.success).toBe(true);
expect(result.package).toEqual(packageData);
});
it('should return error for invalid JSON', () => {
const result = parseSkillPackage('not valid json');
expect(result.success).toBe(false);
expect(result.error).toContain('Failed to parse skill package');
});
it('should return error for invalid package structure', () => {
const result = parseSkillPackage({ skill: {}, markdown: '' });
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
// ==================== sanitizeSkillName ====================
describe('sanitizeSkillName', () => {
it('should trim whitespace', () => {
expect(sanitizeSkillName(' Test Skill ')).toBe('test_skill');
});
it('should convert to lowercase', () => {
expect(sanitizeSkillName('TestSKILL')).toBe('testskill');
});
it('should replace special characters with underscore', () => {
expect(sanitizeSkillName('test@skill#123')).toBe('test_skill_123');
});
it('should replace spaces with underscore', () => {
expect(sanitizeSkillName('test skill name')).toBe('test_skill_name');
});
it('should collapse multiple underscores', () => {
expect(sanitizeSkillName('test___skill')).toBe('test_skill');
});
it('should limit to 50 characters', () => {
const longName = 'a'.repeat(60);
expect(sanitizeSkillName(longName).length).toBe(50);
});
it('should preserve Chinese characters', () => {
expect(sanitizeSkillName('测试技能')).toBe('测试技能');
});
it('should handle mixed Chinese and English', () => {
expect(sanitizeSkillName('Test 技能 @ Name')).toBe('test_技能_name');
});
});
// ==================== createSkillTemplate ====================
describe('createSkillTemplate', () => {
it('should create template with provided name', () => {
const template = createSkillTemplate('My Skill');
expect(template.skill.name).toBe('My Skill');
});
it('should create template with default name when empty', () => {
const template = createSkillTemplate('');
expect(template.skill.name).toBe('New Skill');
});
it('should have required structure', () => {
const template = createSkillTemplate('Test');
expect(template.skill.description).toBeDefined();
expect(template.skill.category).toEqual(['other']);
});
});
});
......@@ -65,8 +65,8 @@ describe('getAgentSkillInfos', () => {
]);
const skill1VersionId = new Types.ObjectId();
const skill2VersionId = new Types.ObjectId();
const skill1TargetDir = `/workspace/skills/${String(skill1._id)}`;
const skill2TargetDir = `/workspace/skills/${String(skill2._id)}`;
const skill1TargetDir = `/workspace/projects/${String(skill1._id)}`;
const skill2TargetDir = `/workspace/projects/${String(skill2._id)}`;
const [skill1Package, skill2Package] = await Promise.all([
makePackage([
......@@ -171,7 +171,15 @@ description: Zeta skill
makeWriteResults(entries)
),
execute: vi.fn(async (command: string) => {
if (command === "rm -rf '/workspace/skills' && mkdir -p '/workspace/skills'") {
if (command === "rm -rf '/workspace/projects' && mkdir -p '/workspace/projects'") {
return {
exitCode: 0,
stdout: '',
stderr: ''
};
}
if (command.includes('unzip')) {
return {
exitCode: 0,
stdout: '',
......@@ -181,8 +189,7 @@ description: Zeta skill
if (command.includes('-iname "SKILL.md"')) {
const matchedPaths = skillMdPaths.filter((path) => {
const dirMatch = command.match(/find\s+'([^']+)'/);
return dirMatch ? path === dirMatch[1] || path.startsWith(`${dirMatch[1]}/`) : true;
return command.includes(path.split('/')[3]);
});
return {
exitCode: 0,
......@@ -210,21 +217,22 @@ description: Zeta skill
workDirectory: '/workspace'
});
expect(sandbox.writeFiles).toHaveBeenCalledTimes(2);
expect(sandbox.writeFiles).toHaveBeenCalledTimes(1);
expect(skill1TargetDir).not.toBe(skill2TargetDir);
const writtenFilePaths = sandbox.writeFiles.mock.calls.flatMap(([entries]) =>
entries.map((entry: { path: string }) => entry.path)
const writtenFilePaths = sandbox.writeFiles.mock.calls[0][0].map(
(entry: { path: string }) => entry.path
);
expect(writtenFilePaths).toEqual(
expect.arrayContaining([
`${skill1TargetDir}/skill1/skill.md`,
`${skill1TargetDir}/skill2/1/skill.md`,
`${skill1TargetDir}/skill2/2/skill.md`,
`${skill2TargetDir}/skill1/skill.md`,
`${skill2TargetDir}/skill2/1/skill.md`,
`${skill2TargetDir}/skill2/2/skill.md`
])
expect.arrayContaining([`${skill1TargetDir}/package.zip`, `${skill2TargetDir}/package.zip`])
);
const unzipCommands = sandbox.execute.mock.calls
.map(([command]) => command)
.filter((command) => command.includes('unzip'));
expect(unzipCommands).toHaveLength(1);
expect(unzipCommands[0]).toContain(`cd '${skill1TargetDir}'`);
expect(unzipCommands[0]).toContain(`cd '${skill2TargetDir}'`);
expect(unzipCommands[0]).toContain('unzip -o -q package.zip');
const findSkillCommands = sandbox.execute.mock.calls
.map(([command]) => command)
.filter((command) => command.includes('-iname "SKILL.md"'));
......@@ -269,8 +277,8 @@ description: Zeta skill
]);
const existingSkillVersionId = new Types.ObjectId();
const missingSkillVersionId = new Types.ObjectId();
const existingSkillTargetDir = `/workspace/skills/${String(existingSkill._id)}`;
const missingSkillTargetDir = `/workspace/skills/${String(missingSkill._id)}`;
const existingSkillTargetDir = `/workspace/projects/${String(existingSkill._id)}`;
const missingSkillTargetDir = `/workspace/projects/${String(missingSkill._id)}`;
const [existingSkillPackage, missingSkillPackage] = await Promise.all([
makePackage([{ path: 'skill.md', name: 'existing', description: 'Existing skill' }]),
......@@ -337,7 +345,15 @@ description: Missing skill
makeWriteResults(entries)
),
execute: vi.fn(async (command: string) => {
if (command === "rm -rf '/workspace/skills' && mkdir -p '/workspace/skills'") {
if (command === "rm -rf '/workspace/projects' && mkdir -p '/workspace/projects'") {
return {
exitCode: 0,
stdout: '',
stderr: ''
};
}
if (command.includes('unzip')) {
return {
exitCode: 0,
stdout: '',
......@@ -348,8 +364,7 @@ description: Missing skill
if (command.includes('-iname "SKILL.md"')) {
const allPaths = Array.from(contentByPath.keys());
const matchedPaths = allPaths.filter((path) => {
const dirMatch = command.match(/find\s+'([^']+)'/);
return dirMatch ? path === dirMatch[1] || path.startsWith(`${dirMatch[1]}/`) : true;
return command.includes(path.split('/')[3]);
});
return {
exitCode: 0,
......@@ -377,18 +392,18 @@ description: Missing skill
workDirectory: '/workspace'
});
expect(sandbox.writeFiles).toHaveBeenCalledTimes(2);
const writtenFilePaths = sandbox.writeFiles.mock.calls.flatMap(([entries]) =>
entries.map((entry: { path: string }) => entry.path)
expect(sandbox.writeFiles).toHaveBeenCalledTimes(1);
const writtenFilePaths = sandbox.writeFiles.mock.calls[0][0].map(
(entry: { path: string }) => entry.path
);
expect(writtenFilePaths).toEqual(
expect.arrayContaining([
`${existingSkillTargetDir}/skill.md`,
`${missingSkillTargetDir}/skill.md`
`${existingSkillTargetDir}/package.zip`,
`${missingSkillTargetDir}/package.zip`
])
);
expect(sandbox.execute).toHaveBeenCalledWith(
"rm -rf '/workspace/skills' && mkdir -p '/workspace/skills'"
"rm -rf '/workspace/projects' && mkdir -p '/workspace/projects'"
);
const findSkillCommands = sandbox.execute.mock.calls
.map(([command]) => command)
......@@ -451,15 +466,23 @@ description: Missing skill
{ $set: { currentVersionId: latestVersionId } }
);
const oldTargetDir = `/workspace/skills/${String(skill._id)}/MultiActive-${oldVersionId}`;
const latestTargetDir = `/workspace/skills/${String(skill._id)}`;
const oldTargetDir = `/workspace/projects/${String(skill._id)}/MultiActive-${oldVersionId}`;
const latestTargetDir = `/workspace/projects/${String(skill._id)}`;
const latestSkillMdPath = `${latestTargetDir}/skill.md`;
const sandbox = {
writeFiles: vi.fn(async (entries: Array<{ path: string; data: Buffer }>) =>
makeWriteResults(entries)
),
execute: vi.fn(async (command: string) => {
if (command === "rm -rf '/workspace/skills' && mkdir -p '/workspace/skills'") {
if (command === "rm -rf '/workspace/projects' && mkdir -p '/workspace/projects'") {
return {
exitCode: 0,
stdout: '',
stderr: ''
};
}
if (command.includes('unzip')) {
return {
exitCode: 0,
stdout: '',
......@@ -499,7 +522,7 @@ description: Latest current skill
expect(
sandbox.writeFiles.mock.calls[0][0].map((entry: { path: string }) => entry.path)
).toEqual(expect.arrayContaining([latestSkillMdPath]));
).toEqual(expect.arrayContaining([`${latestTargetDir}/package.zip`]));
expect(sandbox.execute).not.toHaveBeenCalledWith(expect.stringContaining(oldTargetDir));
expect(result).toEqual([
{
......@@ -545,7 +568,7 @@ description: Latest current skill
{ $set: { currentVersionId: skillVersionId } }
);
const skillTargetDir = `/workspace/skills/${String(skill._id)}`;
const skillTargetDir = `/workspace/projects/${String(skill._id)}`;
const writeError = new Error('write failed');
const sandbox = {
writeFiles: vi.fn(async (entries: Array<{ path: string; data: Buffer }>) =>
......@@ -556,7 +579,7 @@ description: Latest current skill
}))
),
execute: vi.fn(async (command: string) => {
if (command === "rm -rf '/workspace/skills' && mkdir -p '/workspace/skills'") {
if (command === "rm -rf '/workspace/projects' && mkdir -p '/workspace/projects'") {
return {
exitCode: 0,
stdout: '',
......@@ -586,7 +609,7 @@ description: Latest current skill
teamId,
workDirectory: '/workspace'
})
).rejects.toThrow('Failed to write skill package "Broken" file');
).rejects.toThrow('Failed to write skill ZIP packages: write failed');
expect(sandbox.execute).toHaveBeenCalledWith(`rm -rf '${skillTargetDir}'`);
expect(sandbox.readFiles).not.toHaveBeenCalled();
});
......
import { describe, expect, it } from 'vitest';
import { getSafeSkillDirectoryName } from '@fastgpt/service/core/ai/skill/runtime';
import { getSafeSkillDirectoryName } from '@fastgpt/service/core/ai/skill/utils';
describe('getSafeSkillDirectoryName', () => {
it('should format normal English and Chinese names with spaces correctly', () => {
......
import { describe, expect, it } from 'vitest';
import {
createSkillPackage,
addFileToZip,
generateZipBuffer,
validateZipStructure,
extractSkillPackage,
normalizeSkillPackageZipForSandbox,
extractNormalizedSkillPackageFilesForSandbox,
standardizeSkillPackageBySkillMdName,
JSZip
} from '@fastgpt/service/core/ai/skill/package';
......@@ -115,72 +111,6 @@ ${largeMarkdown}`;
});
});
// ==================== addFileToZip ====================
describe('addFileToZip', () => {
it('should add string content to zip', async () => {
const zip = new JSZip();
const content = 'File content here';
addFileToZip(zip, 'test.txt', content);
const files = Object.keys(zip.files);
expect(files).toContain('test.txt');
const fileContent = await zip.file('test.txt')?.async('string');
expect(fileContent).toBe(content);
});
it('should add buffer content to zip', async () => {
const zip = new JSZip();
const content = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
addFileToZip(zip, 'image.png', content);
const fileContent = await zip.file('image.png')?.async('uint8array');
expect(Buffer.from(fileContent!)).toEqual(content);
});
it('should handle nested paths', async () => {
const zip = new JSZip();
addFileToZip(zip, 'assets/images/icon.png', Buffer.from('png'));
addFileToZip(zip, 'docs/README.md', '# Docs');
const files = Object.keys(zip.files);
expect(files).toContain('assets/images/icon.png');
expect(files).toContain('docs/README.md');
});
});
// ==================== generateZipBuffer ====================
describe('generateZipBuffer', () => {
it('should generate valid zip buffer', async () => {
const zip = new JSZip();
zip.file('test.txt', 'content');
const buffer = await generateZipBuffer(zip);
expect(Buffer.isBuffer(buffer)).toBe(true);
expect(buffer.length).toBeGreaterThan(0);
// Verify it's a valid zip
const loadedZip = await JSZip.loadAsync(buffer);
expect(Object.keys(loadedZip.files)).toContain('test.txt');
});
it('should generate empty zip for empty JSZip', async () => {
const zip = new JSZip();
const buffer = await generateZipBuffer(zip);
expect(Buffer.isBuffer(buffer)).toBe(true);
// Empty zip should still be loadable
const loadedZip = await JSZip.loadAsync(buffer);
expect(Object.keys(loadedZip.files)).toHaveLength(0);
});
});
// ==================== validateZipStructure ====================
describe('validateZipStructure', () => {
it('should validate zip with SKILL.md at root', async () => {
......@@ -295,104 +225,6 @@ ${largeMarkdown}`;
});
});
// ==================== normalizeSkillPackageZipForSandbox ====================
describe('normalizeSkillPackageZipForSandbox', () => {
it('keeps a single package root directory (innermost) for sandbox extraction', async () => {
const zip = new JSZip();
zip.file('my-skill/SKILL.md', '---\nname: test\n---');
zip.file('my-skill/src/main.ts', 'export default 1;');
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
const normalizedBuffer = await normalizeSkillPackageZipForSandbox(buffer);
const normalizedZip = await JSZip.loadAsync(normalizedBuffer);
const files = Object.keys(normalizedZip.files).filter(
(path) => !normalizedZip.files[path].dir
);
expect(files).toEqual(expect.arrayContaining(['my-skill/SKILL.md', 'my-skill/src/main.ts']));
});
it('strips nested archive roots down to the real skill root (keeps innermost folder)', async () => {
const zip = new JSZip();
zip.file('archive-root/my-skill/SKILL.md', '---\nname: test\n---');
zip.file('archive-root/my-skill/src/main.ts', 'export default 1;');
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
const normalizedBuffer = await normalizeSkillPackageZipForSandbox(buffer);
const normalizedZip = await JSZip.loadAsync(normalizedBuffer);
const files = Object.keys(normalizedZip.files).filter(
(path) => !normalizedZip.files[path].dir
);
expect(files).toEqual(expect.arrayContaining(['my-skill/SKILL.md', 'my-skill/src/main.ts']));
expect(files).not.toContain('archive-root/my-skill/SKILL.md');
});
it('keeps root-level packages unchanged', async () => {
const zip = new JSZip();
zip.file('SKILL.md', '---\nname: test\n---');
zip.file('src/main.ts', 'export default 1;');
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
const normalizedBuffer = await normalizeSkillPackageZipForSandbox(buffer);
const normalizedZip = await JSZip.loadAsync(normalizedBuffer);
const files = Object.keys(normalizedZip.files).filter(
(path) => !normalizedZip.files[path].dir
);
expect(files).toEqual(expect.arrayContaining(['SKILL.md', 'src/main.ts']));
});
it('keeps multi-root packages unchanged', async () => {
const zip = new JSZip();
zip.file('skill-a/SKILL.md', '---\nname: a\n---');
zip.file('skill-b/SKILL.md', '---\nname: b\n---');
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
const normalizedBuffer = await normalizeSkillPackageZipForSandbox(buffer);
const normalizedZip = await JSZip.loadAsync(normalizedBuffer);
const files = Object.keys(normalizedZip.files).filter(
(path) => !normalizedZip.files[path].dir
);
expect(files).toEqual(expect.arrayContaining(['skill-a/SKILL.md', 'skill-b/SKILL.md']));
});
it('strips a common archive root from multi-skill packages', async () => {
const zip = new JSZip();
zip.file('1/test/SKILL.md', '---\nname: test\n---');
zip.file('1/test2/SKILL.md', '---\nname: test2\n---');
zip.file('1/shared/readme.md', '# shared');
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
const normalizedBuffer = await normalizeSkillPackageZipForSandbox(buffer);
const normalizedZip = await JSZip.loadAsync(normalizedBuffer);
const files = Object.keys(normalizedZip.files).filter(
(path) => !normalizedZip.files[path].dir
);
expect(files).toEqual(
expect.arrayContaining(['test/SKILL.md', 'test2/SKILL.md', 'shared/readme.md'])
);
expect(files).not.toContain('1/test/SKILL.md');
expect(files).not.toContain('1/test2/SKILL.md');
});
it('preserves Chinese directory names when extracting normalized files for sandbox writes', async () => {
const zip = new JSZip();
zip.file('skills/测试的/SKILL.md', '---\nname: 测试的\n---');
zip.file('skills/测试的/test_file.txt', 'hello');
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
const files = await extractNormalizedSkillPackageFilesForSandbox(buffer);
expect(files.map((file) => file.path)).toEqual(
expect.arrayContaining(['测试的/SKILL.md', '测试的/test_file.txt'])
);
expect(files.map((file) => file.path).join('\n')).not.toContain('���');
});
});
describe('standardizeSkillPackageBySkillMdName', () => {
it('uses SKILL.md frontmatter name as package root instead of the existing directory name', async () => {
const zip = new JSZip();
......
......@@ -4,16 +4,7 @@ import { authSkill } from '@fastgpt/service/support/permission/skill/auth';
import { WritePermissionVal } from '@fastgpt/global/support/permission/constant';
import { TeamSkillCreatePermissionVal } from '@fastgpt/global/support/permission/user/constant';
import { importSkill } from '@fastgpt/service/core/ai/skill/manage';
import {
findSkillMdKey,
getRootPrefix,
repackFileMapAsZip,
getSupportedArchiveFormat,
extractToFileMap,
normalizeSkillWorkspaceRoot,
hasSkillsDirectoryContent,
stripRootPrefix
} from '@fastgpt/service/core/ai/skill/package';
import { getZipFileList } from '@fastgpt/service/core/ai/skill/package';
import {
ImportSkillBodySchema,
type ImportSkillBody,
......@@ -64,8 +55,7 @@ async function handler(req: ApiRequestProps<ImportSkillBody>): Promise<ImportSki
bodySchema: ImportSkillBodySchema
}).body;
const format = getSupportedArchiveFormat(file.originalname ?? '');
if (!format) {
if (!file.originalname?.toLowerCase().endsWith('.zip')) {
return Promise.reject(SkillErrEnum.invalidArchiveFormat);
}
......@@ -106,17 +96,6 @@ async function handler(req: ApiRequestProps<ImportSkillBody>): Promise<ImportSki
return Promise.reject(SkillErrEnum.archiveTooLarge);
}
// Extract archive to file map
let fileMap: Record<string, Buffer>;
try {
fileMap = await extractToFileMap(file.path, maxUncompressedBytes);
} catch (err: any) {
logger.warn('Failed to extract archive', { error: err.message });
return Promise.reject(SkillErrEnum.archiveExtractionFailed);
}
if (Object.keys(fileMap).length === 0) {
return Promise.reject(SkillErrEnum.archiveEmpty);
}
// Derive package-level name from caller-supplied value or archive filename
const pkgName =
body.name ||
......@@ -124,26 +103,16 @@ async function handler(req: ApiRequestProps<ImportSkillBody>): Promise<ImportSki
'package';
const pkgDescription = body.description ?? '';
fileMap = normalizeSkillWorkspaceRoot(fileMap);
const finalFileMap: Record<string, Buffer> = {};
if (hasSkillsDirectoryContent(fileMap)) {
Object.assign(finalFileMap, fileMap);
} else {
const skillMdKey = findSkillMdKey(fileMap);
if (!skillMdKey) {
return Promise.reject(SkillErrEnum.invalidSkillPackage);
}
// Directly read the ZIP archive buffer from disk without any in-memory decompression
const zipBuffer = await fs.readFile(file.path);
const singleSkillFileMap = stripRootPrefix(fileMap, getRootPrefix(skillMdKey));
const prefix = `skills/${pkgName}/`;
for (const [key, value] of Object.entries(singleSkillFileMap)) {
finalFileMap[`${prefix}${key}`] = value;
}
// Light-weight integrity validation to ensure the uploaded ZIP is a valid skill package containing SKILL.md
const filesList = await getZipFileList(zipBuffer);
const hasSkillMd = filesList.some((path) => path.toLowerCase().endsWith('skill.md'));
if (!hasSkillMd) {
return Promise.reject(SkillErrEnum.invalidSkillPackage);
}
// Repack the workspace fileMap as a single ZIP (converts TAR/TAR.GZ to ZIP)
const zipBuffer = await repackFileMapAsZip(finalFileMap);
// Build skill package using package-level metadata only
const skillPackage: SkillPackageType = {
skill: {
......@@ -154,7 +123,7 @@ async function handler(req: ApiRequestProps<ImportSkillBody>): Promise<ImportSki
}
};
// Create ONE DB record
// Create ONE DB record and upload the raw ZIP buffer straight to S3
const skillId = await importSkill(
skillPackage,
teamId,
......
......@@ -56,7 +56,7 @@ describe('skill/import invalid package', () => {
await expect(MongoAgentSkillsVersion.countDocuments({ tmbId: user.tmbId })).resolves.toBe(0);
});
it('单 skill 压缩包会按压缩包名导入为 skills/<pkgName>/ 结构', async () => {
it('单 skill 压缩包会按原始上传结构保存', async () => {
const user = await getUser(`skill-import-single-${getNanoid(6)}`);
const zip = new JSZip();
zip.file('single-skill/SKILL.md', '---\nname: single\n---\n');
......@@ -87,8 +87,8 @@ describe('skill/import invalid package', () => {
const stored = await JSZip.loadAsync(storedZip);
const files = Object.keys(stored.files).filter((key) => !stored.files[key].dir);
expect(files).toEqual(
expect.arrayContaining(['skills/single-skill/SKILL.md', 'skills/single-skill/src/main.ts'])
expect.arrayContaining(['single-skill/SKILL.md', 'single-skill/src/main.ts'])
);
expect(files).not.toContain('single-skill/SKILL.md');
expect(files).not.toContain('skills/single-skill/SKILL.md');
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment