Skip to content

Commit 9f1a0c4

Browse files
committed
feat: Add skill size limits and improve package handling
1 parent 83675db commit 9f1a0c4

File tree

6 files changed

+87
-15
lines changed

6 files changed

+87
-15
lines changed

packages/service/core/agentSkills/controller.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { MongoAgentSkills } from './schema';
2+
import { MongoAgentSkillsVersion } from './versionSchema';
23
import { AgentSkillSourceEnum } from '@fastgpt/global/core/agentSkills/constants';
34
import type {
45
AgentSkillSchemaType,
56
AgentSkillListItemType,
67
SkillPackageType
78
} from '@fastgpt/global/core/agentSkills/type';
89
import type { ClientSession } from '../../common/mongo';
9-
import { uploadSkillPackage } from './storage';
10+
import { uploadSkillPackage, deleteSkillAllPackages } from './storage';
1011
import { createVersion } from './versionController';
1112

1213
// Types for service operations
@@ -115,11 +116,20 @@ export async function deleteSkill(skillId: string, session?: ClientSession): Pro
115116
throw new Error('Cannot delete system skill');
116117
}
117118

119+
// Soft delete the skill record
118120
await MongoAgentSkills.updateOne(
119121
{ _id: skillId },
120122
{ $set: { deleteTime: new Date() } },
121123
{ session }
122124
);
125+
126+
// Soft delete all version records for this skill
127+
await MongoAgentSkillsVersion.updateMany({ skillId }, { $set: { isDeleted: true } }, { session });
128+
129+
// Queue Minio file deletion after DB changes — runs outside the session (S3 is not transactional)
130+
if (skill.teamId) {
131+
deleteSkillAllPackages(skill.teamId.toString(), skillId);
132+
}
123133
}
124134

125135
/**

packages/service/core/agentSkills/sandboxConfig.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,31 @@ export function getSandboxDefaults(): SandboxDefaults {
6464
};
6565
}
6666

67+
export type SkillSizeLimits = {
68+
maxUploadBytes: number; // Compressed upload size limit
69+
maxUncompressedBytes: number; // Uncompressed size after extraction (Zip Bomb guard)
70+
maxDownloadBytes: number; // Download from MinIO/S3
71+
maxSandboxPackageBytes: number; // Sandbox directory size before zip
72+
};
73+
74+
/**
75+
* Get skill size limits from environment variables
76+
*/
77+
export function getSkillSizeLimits(): SkillSizeLimits {
78+
return {
79+
maxUploadBytes: safeParseInt(process.env.AGENT_SKILL_MAX_UPLOAD_SIZE, 50 * 1024 * 1024),
80+
maxUncompressedBytes: safeParseInt(
81+
process.env.AGENT_SKILL_MAX_UNCOMPRESSED_SIZE,
82+
200 * 1024 * 1024
83+
),
84+
maxDownloadBytes: safeParseInt(process.env.AGENT_SKILL_MAX_DOWNLOAD_SIZE, 200 * 1024 * 1024),
85+
maxSandboxPackageBytes: safeParseInt(
86+
process.env.AGENT_SKILL_MAX_SANDBOX_SIZE,
87+
200 * 1024 * 1024
88+
)
89+
};
90+
}
91+
6792
/**
6893
* Validate sandbox configuration
6994
*/

packages/service/core/agentSkills/sandboxController.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {
1616
getSandboxProviderConfig,
1717
getSandboxDefaults,
1818
validateSandboxConfig,
19-
buildDockerSyncEnv
19+
buildDockerSyncEnv,
20+
getSkillSizeLimits
2021
} from './sandboxConfig';
2122
import type {
2223
SkillSandboxSchemaType,
@@ -470,13 +471,14 @@ export async function renewSandboxExpiration(
470471
* @param params.workDirectory - Working directory (defaults to homeDirectory)
471472
* @returns Buffer containing the package.zip file
472473
*
473-
* @throws Error if packaging fails or file cannot be read
474+
* @throws Error if packaging fails, file cannot be read, or directory exceeds size limit
474475
*/
475476
export async function packageSkillInSandbox(params: {
476477
providerSandboxId: string;
477478
workDirectory?: string;
478479
}): Promise<Buffer> {
479480
const { providerSandboxId, workDirectory } = params;
481+
const { maxSandboxPackageBytes: maxBytes } = getSkillSizeLimits();
480482

481483
const providerConfig = getSandboxProviderConfig();
482484
const defaults = getSandboxDefaults();
@@ -503,6 +505,28 @@ export async function packageSkillInSandbox(params: {
503505
// Connect to existing sandbox
504506
await sandbox.connect(providerSandboxId);
505507

508+
// Fast path: check directory size before expensive zip operation
509+
// Use 'find -ls | awk' instead of 'du' for better portability:
510+
// 'du' reports disk-block usage and its flags (-sb, --bytes) differ across GNU coreutils,
511+
// busybox (Alpine), and BSD; 'find -ls' is POSIX and outputs per-file byte sizes in $7
512+
// uniformly across all those environments.
513+
const sizeCheckCmd = `find ${targetDir} -type f ! -name 'package.zip' -ls | awk '{s+=$7} END {print s+0}'`;
514+
addLog.info('[Sandbox] Checking directory size before packaging');
515+
const sizeResult = await sandbox.execute(sizeCheckCmd);
516+
517+
if (sizeResult.exitCode === 0 && sizeResult.stdout.trim()) {
518+
const dirBytes = parseInt(sizeResult.stdout.trim(), 10);
519+
if (!isNaN(dirBytes) && dirBytes > maxBytes) {
520+
throw new Error(
521+
`Skill directory size (${(dirBytes / 1024 / 1024).toFixed(2)}MB) exceeds maximum allowed size (${maxBytes / 1024 / 1024}MB)`
522+
);
523+
}
524+
addLog.info('[Sandbox] Directory size check passed', {
525+
dirBytes,
526+
maxBytes
527+
});
528+
}
529+
506530
// Execute zip command to package the directory
507531
// -r: recursive, -x: exclude pattern (exclude package.zip itself)
508532
const zipCommand = `cd ${targetDir} && zip -r package.zip . -x 'package.zip'`;

packages/service/core/agentSkills/storage.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import { S3PrivateBucket } from '../../common/s3/buckets/private';
99
import type { ClientSession } from '../../common/mongo';
10+
import { getSkillSizeLimits } from './sandboxConfig';
1011

1112
export type SkillStorageInfo = {
1213
bucket: string;
@@ -93,11 +94,9 @@ export async function uploadSkillPackage(
9394
/**
9495
* Download skill package from MinIO/S3 storage
9596
*/
96-
export async function downloadSkillPackage(
97-
params: DownloadSkillPackageParams,
98-
maxBytes = 200 * 1024 * 1024
99-
): Promise<Buffer> {
97+
export async function downloadSkillPackage(params: DownloadSkillPackageParams): Promise<Buffer> {
10098
const { storageInfo } = params;
99+
const { maxDownloadBytes } = getSkillSizeLimits();
101100

102101
const bucket = new S3PrivateBucket();
103102

@@ -115,8 +114,10 @@ export async function downloadSkillPackage(
115114
for await (const chunk of response.body) {
116115
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
117116
totalSize += buf.length;
118-
if (totalSize > maxBytes) {
119-
throw new Error(`Skill package exceeds maximum allowed size (${maxBytes / 1024 / 1024}MB)`);
117+
if (totalSize > maxDownloadBytes) {
118+
throw new Error(
119+
`Skill package exceeds maximum allowed size (${maxDownloadBytes / 1024 / 1024}MB)`
120+
);
120121
}
121122
chunks.push(buf);
122123
}
@@ -135,6 +136,17 @@ export async function deleteSkillPackage(storageInfo: SkillStorageInfo): Promise
135136
});
136137
}
137138

139+
/**
140+
* Delete all packages for a skill across all versions using prefix deletion.
141+
* Fire-and-forget: enqueues to BullMQ and returns immediately.
142+
* Prefix covers: agent-skills/{teamId}/{skillId}/ (all versions)
143+
*/
144+
export function deleteSkillAllPackages(teamId: string, skillId: string): void {
145+
const prefix = `agent-skills/${teamId}/${skillId}/`;
146+
const bucket = new S3PrivateBucket();
147+
bucket.addDeleteJob({ prefix });
148+
}
149+
138150
/**
139151
* Check if skill package exists in storage
140152
*/

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import type { ImportSkillBody, ImportSkillResponse } from '@fastgpt/global/core/agentSkills/api';
1212
import type { SkillPackageType } from '@fastgpt/global/core/agentSkills/type';
1313
import { multer } from '@fastgpt/service/common/file/multer';
14+
import { getSkillSizeLimits } from '@fastgpt/service/core/agentSkills/sandboxConfig';
1415
import fs from 'fs/promises';
1516

1617
export const config = {
@@ -28,8 +29,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
2829
}
2930

3031
// Read env limit before multer so both use the same value
31-
const maxSizeEnv = process.env.MAX_SKILL_ZIP_SIZE;
32-
const maxArchiveSize = maxSizeEnv ? parseInt(maxSizeEnv, 10) : 50 * 1024 * 1024;
32+
const { maxUploadBytes: maxArchiveSize, maxUncompressedBytes } = getSkillSizeLimits();
3333
// Convert bytes to MB for multer (multer expects MB)
3434
const maxArchiveSizeMB = Math.ceil(maxArchiveSize / 1024 / 1024);
3535

@@ -74,7 +74,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
7474
// Extract archive to file map
7575
let fileMap: Record<string, Buffer>;
7676
try {
77-
fileMap = await extractToFileMap(file.path);
77+
fileMap = await extractToFileMap(file.path, maxUncompressedBytes);
7878
} catch (err: any) {
7979
return jsonRes(res, {
8080
code: 400,

projects/sandbox-sync-agent/base/Dockerfile

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ RUN apt-get update && apt-get install -y \
1515
unzip \
1616
curl \
1717
ca-certificates \
18-
sudo \
19-
python3 \
20-
&& rm -rf /var/lib/apt/lists/*
18+
sudo
19+
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
20+
RUN apt install -y python3 nodejs
21+
RUN rm -rf /var/lib/apt/lists/*
2122

2223
# Install code-server using the official script
2324
RUN curl -fsSL https://code-server.dev/install.sh | sh

0 commit comments

Comments
 (0)