Skip to content

Commit 83675db

Browse files
committed
feat: Enhance agent skills API with validation and limits
1 parent 106f116 commit 83675db

File tree

17 files changed

+486
-451
lines changed

17 files changed

+486
-451
lines changed

packages/global/core/agentSkills/api.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,15 @@ export type GetSkillDetailResponse = {
8181
};
8282

8383
// ==================== Import Skill ====================
84+
/**
85+
* ImportSkillBody — optional metadata fields for multipart/form-data upload.
86+
* The actual archive file (ZIP / TAR / TAR.GZ) must be sent as the `file` field
87+
* in a multipart/form-data request; it is not represented in this type.
88+
*/
8489
export type ImportSkillBody = {
8590
name?: string; // required for multi-skill packages; optional for single-skill (falls back to SKILL.md)
8691
description?: string; // optional, fallback to SKILL.md or ''
92+
avatar?: string; // optional skill icon/avatar URL or base64
8793
};
8894

8995
export type ImportSkillResponse = string; // single skillId

packages/global/core/agentSkills/type.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export type AgentSkillConfigType = {
9393
url: string;
9494
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
9595
headers?: Record<string, string>;
96+
/** Request timeout in seconds */
9697
timeout?: number;
9798
};
9899

packages/service/core/agentSkills/archiveUtils.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,26 @@ export function getSupportedArchiveFormat(filename: string): ArchiveFormat | nul
1515
/**
1616
* Extract archive file (zip/tar/tar.gz) to a file map.
1717
* Path traversal entries are filtered out for security.
18+
* Total uncompressed size is capped to prevent Zip Bomb OOM attacks.
1819
*/
19-
export async function extractToFileMap(filePath: string): Promise<ArchiveFileMap> {
20+
export async function extractToFileMap(
21+
filePath: string,
22+
maxUncompressedBytes = 200 * 1024 * 1024
23+
): Promise<ArchiveFileMap> {
2024
const files = await decompress(filePath);
2125
const fileMap: ArchiveFileMap = {};
26+
let totalSize = 0;
2227
for (const file of files) {
2328
if (file.type === 'directory') continue;
2429
const normalized = file.path.replace(/\\/g, '/').replace(/^\/+/, '');
2530
// Filter path traversal
2631
if (!normalized || normalized.includes('../')) continue;
32+
totalSize += file.data.length;
33+
if (totalSize > maxUncompressedBytes) {
34+
throw new Error(
35+
`Uncompressed archive exceeds maximum allowed size (${maxUncompressedBytes / 1024 / 1024}MB)`
36+
);
37+
}
2738
fileMap[normalized] = file.data;
2839
}
2940
return fileMap;

packages/service/core/agentSkills/controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,9 @@ export async function listSkills(
157157
query.teamId = teamId;
158158
}
159159

160-
// Category filter
160+
// Category filter — use $in because category is an array field
161161
if (category) {
162-
query.category = category;
162+
query.category = { $in: [category] };
163163
}
164164

165165
// Search key (text search on name and description)

packages/service/core/agentSkills/sandboxConfig.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66

77
import type { SandboxImageConfigType } from '@fastgpt/global/core/agentSkills/type';
88

9+
/** Parse an integer from an env-var string, returning defaultValue when the result is NaN. */
10+
function safeParseInt(value: string | undefined, defaultValue: number): number {
11+
const n = parseInt(value ?? '', 10);
12+
return isNaN(n) ? defaultValue : n;
13+
}
14+
915
export type SandboxProviderConfig = {
1016
provider: string;
1117
baseUrl: string;
@@ -45,9 +51,9 @@ export function getSandboxProviderConfig(): SandboxProviderConfig {
4551
*/
4652
export function getSandboxDefaults(): SandboxDefaults {
4753
return {
48-
timeout: parseInt(process.env.SANDBOX_DEFAULT_TIMEOUT || '600', 10), // 10 minutes, Automatic termination timeout (server-side TTL)
49-
cleanupInterval: parseInt(process.env.SANDBOX_CLEANUP_INTERVAL || '3600000', 10),
50-
inactiveThreshold: parseInt(process.env.SANDBOX_INACTIVE_THRESHOLD || '7200', 10),
54+
timeout: safeParseInt(process.env.SANDBOX_DEFAULT_TIMEOUT, 600), // 10 minutes, Automatic termination timeout (server-side TTL)
55+
cleanupInterval: safeParseInt(process.env.SANDBOX_CLEANUP_INTERVAL, 3600000),
56+
inactiveThreshold: safeParseInt(process.env.SANDBOX_INACTIVE_THRESHOLD, 7200),
5157
defaultImage: {
5258
repository: process.env.SANDBOX_DEFAULT_IMAGE || 'node',
5359
tag: process.env.SANDBOX_DEFAULT_IMAGE_TAG || '18-alpine'

packages/service/core/agentSkills/schema.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,11 @@ try {
8282
AgentSkillsSchema.index({ source: 1, teamId: 1, deleteTime: 1, createTime: -1 });
8383
// Category index
8484
AgentSkillsSchema.index({ category: 1 });
85-
// Removed unique index on name to allow duplicate skill names
86-
// AgentSkillSchema.index(
87-
// { name: 1, teamId: 1, deleteTime: 1 },
88-
// { unique: true, partialFilterExpression: { deleteTime: null } }
89-
// );
85+
// Unique constraint: same team cannot have two live skills with the same name
86+
AgentSkillsSchema.index(
87+
{ name: 1, teamId: 1, deleteTime: 1 },
88+
{ unique: true, partialFilterExpression: { deleteTime: null } }
89+
);
9090
} catch (error) {
9191
console.log('AgentSkill index error:', error);
9292
}

packages/service/core/agentSkills/storage.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,10 @@ export async function uploadSkillPackage(
9393
/**
9494
* Download skill package from MinIO/S3 storage
9595
*/
96-
export async function downloadSkillPackage(params: DownloadSkillPackageParams): Promise<Buffer> {
96+
export async function downloadSkillPackage(
97+
params: DownloadSkillPackageParams,
98+
maxBytes = 200 * 1024 * 1024
99+
): Promise<Buffer> {
97100
const { storageInfo } = params;
98101

99102
const bucket = new S3PrivateBucket();
@@ -106,10 +109,16 @@ export async function downloadSkillPackage(params: DownloadSkillPackageParams):
106109
throw new Error(`Failed to download skill package: ${storageInfo.key}`);
107110
}
108111

109-
// Convert stream to buffer
112+
// Convert stream to buffer with size limit to prevent OOM
110113
const chunks: Buffer[] = [];
114+
let totalSize = 0;
111115
for await (const chunk of response.body) {
112-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
116+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
117+
totalSize += buf.length;
118+
if (totalSize > maxBytes) {
119+
throw new Error(`Skill package exceeds maximum allowed size (${maxBytes / 1024 / 1024}MB)`);
120+
}
121+
chunks.push(buf);
113122
}
114123

115124
return Buffer.concat(chunks);

packages/service/core/agentSkills/versionController.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,17 @@ export async function createVersion(
4646
}
4747

4848
/**
49-
* Get the next version number for a skill
49+
* Get the next version number for a skill.
50+
* Should be called inside a transaction session to avoid version number races.
5051
*/
51-
export async function getNextVersionNumber(skillId: string): Promise<number> {
52+
export async function getNextVersionNumber(
53+
skillId: string,
54+
session?: ClientSession
55+
): Promise<number> {
5256
const lastVersion = await MongoAgentSkillsVersion.findOne(
5357
{ skillId, isDeleted: false },
5458
{ version: 1 },
55-
{ sort: { version: -1 } }
59+
{ sort: { version: -1 }, session }
5660
).lean();
5761

5862
return (lastVersion?.version ?? -1) + 1;
@@ -140,7 +144,8 @@ export async function setActiveVersion(
140144
}
141145

142146
/**
143-
* Soft delete a version
147+
* Soft delete a version.
148+
* Active versions cannot be deleted — deactivate or switch to another version first.
144149
*/
145150
export async function deleteVersion(
146151
skillId: string,
@@ -157,18 +162,24 @@ export async function deleteVersion(
157162
throw new Error(`Version ${version} not found for skill ${skillId}`);
158163
}
159164

160-
// If this is the active version, we should not allow deletion
161-
// or we should deactivate it first
162-
const updateData: Record<string, any> = {
163-
isDeleted: true,
164-
isActive: false
165-
};
165+
// Refuse to delete the currently active version to prevent data orphaning
166+
if (versionDoc.isActive) {
167+
throw new Error(
168+
`Cannot delete active version ${version}. Switch to another version before deleting.`
169+
);
170+
}
166171

167-
await MongoAgentSkillsVersion.updateOne({ skillId, version }, { $set: updateData }, { session });
172+
await MongoAgentSkillsVersion.updateOne(
173+
{ skillId, version },
174+
{ $set: { isDeleted: true } },
175+
{ session }
176+
);
168177
}
169178

170179
/**
171-
* Restore a deleted version
180+
* Restore a deleted version.
181+
* The restored version is set back to isDeleted=false but remains inactive (isActive=false).
182+
* Call setActiveVersion explicitly if you want to make it the active version.
172183
*/
173184
export async function restoreVersion(
174185
skillId: string,

projects/app/src/pages/api/core/app/agent/skills/create.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
5858
});
5959
}
6060

61+
// Validate category enum values
62+
const validCategories = Object.values(AgentSkillCategoryEnum) as string[];
63+
if (category.length > 0 && category.some((c) => !validCategories.includes(c))) {
64+
return jsonRes(res, { code: 400, error: 'Invalid category value' });
65+
}
66+
67+
// Validate config size (max 50 KB)
68+
if (config && JSON.stringify(config).length > 50_000) {
69+
return jsonRes(res, { code: 400, error: 'Config exceeds maximum allowed size (50KB)' });
70+
}
71+
6172
// Check if skill name already exists
6273
const nameExists = await checkSkillNameExists(name.trim(), teamId);
6374
if (nameExists) {
@@ -125,7 +136,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
125136
jsonRes<CreateSkillResponse>(res, {
126137
data: skillId
127138
});
128-
} catch (err) {
139+
} catch (err: any) {
140+
// E11000: unique index violation (concurrent duplicate name creation)
141+
if (err.code === 11000 || err.codeName === 'DuplicateKey') {
142+
return jsonRes(res, { code: 409, error: 'Skill name already exists' });
143+
}
129144
jsonRes(res, {
130145
code: 500,
131146
error: err

projects/app/src/pages/api/core/app/agent/skills/delete.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { authUserPer } from '@fastgpt/service/support/permission/user/auth';
44
import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun';
55
import { deleteSkill, canModifySkill } from '@fastgpt/service/core/agentSkills/controller';
66
import type { DeleteSkillQuery, DeleteSkillResponse } from '@fastgpt/global/core/agentSkills/api';
7+
import { isValidObjectId } from 'mongoose';
78

89
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
910
try {
@@ -33,6 +34,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
3334
});
3435
}
3536

37+
if (!isValidObjectId(skillId)) {
38+
return jsonRes(res, { code: 400, error: 'Invalid skill ID format' });
39+
}
40+
3641
// Check if user can delete this skill
3742
const canDelete = await canModifySkill(skillId, tmbId);
3843
if (!canDelete) {

0 commit comments

Comments
 (0)