Commit 42b661ca by DigHuang Committed by GitHub

refactor(sandbox): unify runtime preparation flow and support package mirrors (#7184)

* refactor(sandbox): unify runtime preparation flow and support package mirrors

* feat: pypi trust url
parent 3fa6b366
......@@ -75,6 +75,8 @@ These variables are mainly validated by `packages/service/env.ts` and apply to `
| `AGENT_SANDBOX_SKILL_MAX_SIZE` | `10` | Maximum Skill sandbox package size, in MB. Used for Skill package upload, download, and publishing checks. |
| `AGENT_SANDBOX_MAX_FILE_SIZE` | `10` | Maximum single-file size for Agent sandbox IDE reads, writes, and uploads, in MB. |
| `AGENT_SANDBOX_MAX_EDIT_DEBUG` | `100` | Limit for Agent edit/debug sandboxes. |
| `AGENT_SANDBOX_NPM_REGISTRY` | Empty | npm registry used by npm, yarn, pnpm, and bun inside Agent sandboxes. |
| `AGENT_SANDBOX_PYPI_INDEX_URL` | Empty | PyPI index URL used by pip, `python -m pip`, and uv inside Agent sandboxes. |
### Databases, Cache, and Vector Stores
......
......@@ -75,6 +75,8 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说
| `AGENT_SANDBOX_SKILL_MAX_SIZE` | `10` | Skill sandbox 包大小上限,单位 MB;用于 Skill 包上传、下载和打包发布校验。 |
| `AGENT_SANDBOX_MAX_FILE_SIZE` | `10` | Agent 沙箱 IDE 单文件读写和上传大小上限,单位 MB。 |
| `AGENT_SANDBOX_MAX_EDIT_DEBUG` | `100` | Agent 编辑/调试沙箱数量限制。 |
| `AGENT_SANDBOX_NPM_REGISTRY` | 空 | Agent 沙箱内 npm、yarn、pnpm、bun 使用的 npm registry。 |
| `AGENT_SANDBOX_PYPI_INDEX_URL` | 空 | Agent 沙箱内 pip、`python -m pip`、uv 使用的 PyPI index URL。 |
### 数据库、缓存与向量库
......
......@@ -167,8 +167,8 @@
"content/plugin/model-presets.mdx": "2026-06-04T16:10:15+08:00",
"content/plugin/system-tool-development.en.mdx": "2026-06-09T16:03:58+08:00",
"content/plugin/system-tool-development.mdx": "2026-06-09T16:03:58+08:00",
"content/self-host/config/env.en.mdx": "2026-06-25T11:22:10+08:00",
"content/self-host/config/env.mdx": "2026-06-25T11:22:10+08:00",
"content/self-host/config/env.en.mdx": "2026-06-25T14:51:00+08:00",
"content/self-host/config/env.mdx": "2026-06-25T14:51:00+08:00",
"content/self-host/config/json.en.mdx": "2026-06-22T11:01:59+08:00",
"content/self-host/config/json.mdx": "2026-06-22T11:01:59+08:00",
"content/self-host/config/model/intro.en.mdx": "2026-06-04T16:10:15+08:00",
......@@ -291,8 +291,8 @@
"content/self-host/upgrading/4-14/41481.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4149.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4149.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-15/41500.en.mdx": "2026-06-25T11:22:10+08:00",
"content/self-host/upgrading/4-15/41500.mdx": "2026-06-25T11:22:10+08:00",
"content/self-host/upgrading/4-15/41500.en.mdx": "2026-06-25T14:51:00+08:00",
"content/self-host/upgrading/4-15/41500.mdx": "2026-06-25T14:51:00+08:00",
"content/self-host/upgrading/4-15/41501.mdx": "2026-06-23T21:09:39+08:00",
"content/self-host/upgrading/4-15/41502.en.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/upgrading/4-15/41502.mdx": "2026-06-23T13:54:06+08:00",
......@@ -302,8 +302,8 @@
"content/self-host/upgrading/4-15/41504.mdx": "2026-06-15T23:34:43+08:00",
"content/self-host/upgrading/4-15/41505.en.mdx": "2026-06-23T13:54:06+08:00",
"content/self-host/upgrading/4-15/41505.mdx": "2026-06-24T13:53:05+08:00",
"content/self-host/upgrading/4-15/41506.en.mdx": "2026-06-25T11:22:10+08:00",
"content/self-host/upgrading/4-15/41506.mdx": "2026-06-25T11:22:10+08:00",
"content/self-host/upgrading/4-15/41506.en.mdx": "2026-06-25T14:51:00+08:00",
"content/self-host/upgrading/4-15/41506.mdx": "2026-06-25T14:51:00+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......@@ -444,6 +444,6 @@
"content/self-host/upgrading/outdated/499.mdx": "2026-05-07T15:06:40+08:00",
"content/self-host/upgrading/upgrade-intruction.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/upgrade-intruction.mdx": "2026-04-26T21:08:47+08:00",
"content/toc.en.mdx": "2026-06-25T11:22:10+08:00",
"content/toc.mdx": "2026-06-25T11:22:10+08:00"
"content/toc.en.mdx": "2026-06-25T14:51:00+08:00",
"content/toc.mdx": "2026-06-25T14:51:00+08:00"
}
\ No newline at end of file
......@@ -3,11 +3,12 @@ import { getLogger, LogCategories } from '../../../../common/logger';
import { serviceEnv } from '../../../../env';
import { isRedisLeaseError, withRedisLease } from '../../../../common/redis/lock';
import { createAgentSandboxInitializingError } from '../error';
import type { SandboxPrepareContext, SandboxPrepareStep } from './prepare';
import { buildRuntimeHash, shellQuote } from './utils';
import {
getRuntimeStateHash,
getRuntimeStateValue,
readSandboxRuntimeState,
setRuntimeStateHash,
setRuntimeStateValue,
writeSandboxRuntimeState
} from './state';
......@@ -66,7 +67,7 @@ export const runAgentSandboxEntrypoint = async ({
const stateContext = await readSandboxRuntimeState({ sandbox });
const scriptHash = buildRuntimeHash(script);
if (getRuntimeStateHash(stateContext.state, SANDBOX_ENTRYPOINT_STATE_HASH_KEY) === scriptHash) {
if (getRuntimeStateValue(stateContext.state, SANDBOX_ENTRYPOINT_STATE_HASH_KEY) === scriptHash) {
return;
}
......@@ -79,10 +80,26 @@ export const runAgentSandboxEntrypoint = async ({
if (!result) return;
setRuntimeStateHash(stateContext.state, SANDBOX_ENTRYPOINT_STATE_HASH_KEY, scriptHash);
setRuntimeStateValue(stateContext.state, SANDBOX_ENTRYPOINT_STATE_HASH_KEY, scriptHash);
await writeSandboxRuntimeState(sandbox, stateContext);
};
/** 将 sandbox entrypoint 包装为 prepare step,便于和文件注入、skill 扫描按顺序编排。 */
export const runSandboxEntrypoint =
<Context extends SandboxPrepareContext>({
sandboxEntrypoint
}: {
sandboxEntrypoint?: string;
}): SandboxPrepareStep<Context> =>
async (context) => {
await runAgentSandboxEntrypoint({
sandbox: context.sandbox,
sandboxEntrypoint,
workDirectory: context.workDirectory
});
return context;
};
export const executeEntrypointCommand = async ({
sandbox,
command,
......
import type { FileWriteEntry, ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { SANDBOX_USER_FILES_PATH } from '@fastgpt/global/core/ai/sandbox/constants';
import { pickOutboundAxios } from '../../../../common/api/axios';
import { getSafeAgentInputFilename } from '../../../workflow/dispatch/ai/agent/adapter/fileName';
import { getSafeSandboxInputFilename } from './utils';
export type SandboxInputFile = {
name: string;
......@@ -41,7 +41,7 @@ export const injectInputFilesToSandbox = async (sandbox: ISandbox, files: Sandbo
const usedNames = new Map<string, number>();
for (const [index, file] of files.entries()) {
const filename = getSafeAgentInputFilename(file.name, index, usedNames);
const filename = getSafeSandboxInputFilename(file.name, index, usedNames);
const path = `${SANDBOX_USER_FILES_PATH}${filename}`;
writeFileTasks.push(
pickOutboundAxios(file.url)
......
......@@ -19,18 +19,14 @@ export async function prepareAgentSandboxRuntime({
userId,
chatId,
sandboxId,
teamId,
needSandboxRuntime
teamId
}: {
appId: string;
userId: string;
chatId: string;
sandboxId?: string;
teamId: string;
needSandboxRuntime: boolean;
}): Promise<AgentSandboxRuntimeContext | undefined> {
if (!needSandboxRuntime) return;
}): Promise<AgentSandboxRuntimeContext> {
try {
await checkTeamSandboxPermission(teamId);
} catch {
......
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import { getLogger, LogCategories } from '../../../../common/logger';
import { serviceEnv } from '../../../../env';
import { buildRuntimeHash, joinSandboxPath, shellQuote } from './utils';
import {
getRuntimeStateValue,
readSandboxRuntimeState,
setRuntimeStateValue,
writeSandboxRuntimeState
} from './state';
import { resolveSandboxHome } from './home';
const logger = getLogger(LogCategories.MODULE.AI.SANDBOX);
const SANDBOX_MIRRORS_STATE_HASH_KEY = 'sandboxPackageMirrors';
export type SandboxRuntimeMirrorsConfig = {
npmRegistry?: string;
pypiIndexUrl?: string;
};
export const getSandboxRuntimeMirrorsConfig = (): SandboxRuntimeMirrorsConfig =>
normalizeMirrorsConfig({
npmRegistry: serviceEnv.AGENT_SANDBOX_NPM_REGISTRY,
pypiIndexUrl: serviceEnv.AGENT_SANDBOX_PYPI_INDEX_URL
});
export const prepareSandboxRuntimeMirrors = async ({
sandbox,
config = getSandboxRuntimeMirrorsConfig()
}: {
sandbox: ISandbox;
config?: SandboxRuntimeMirrorsConfig;
}): Promise<void> => {
const files = buildSandboxRuntimeMirrorFiles(config);
if (files.length === 0) return;
const homeDirectory = await resolveSandboxHome(sandbox);
if (!homeDirectory) return;
const stateContext = await readSandboxRuntimeState({ sandbox, homeDirectory });
if (!stateContext.statePath) return;
const mirrorScript = buildSandboxRuntimeMirrorScript({ homeDirectory, files });
const scriptHash = buildRuntimeHash(mirrorScript);
if (getRuntimeStateValue(stateContext.state, SANDBOX_MIRRORS_STATE_HASH_KEY) === scriptHash) {
return;
}
const result = await sandbox
.execute(mirrorScript, {
timeoutMs: 5_000,
maxOutputBytes: 1024
})
.catch((error) => {
logger.warn('[Sandbox Runtime] Failed to execute mirror config script', { error });
return undefined;
});
if (!result || result.exitCode !== 0) {
logger.warn('[Sandbox Runtime] Failed to execute mirror config script', {
exitCode: result?.exitCode,
stderr: result?.stderr
});
return;
}
setRuntimeStateValue(stateContext.state, SANDBOX_MIRRORS_STATE_HASH_KEY, scriptHash);
await writeSandboxRuntimeState(sandbox, stateContext);
};
const buildSandboxRuntimeMirrorFiles = (config: SandboxRuntimeMirrorsConfig) => {
const normalized = normalizeMirrorsConfig(config);
const files: Array<{ path: string; content: string }> = [];
if (normalized.npmRegistry) {
files.push({
path: '.npmrc',
content: `registry=${normalized.npmRegistry}\n`
});
files.push({
path: '.yarnrc',
content: `registry "${normalized.npmRegistry}"\n`
});
files.push({
path: '.yarnrc.yml',
content: `npmRegistryServer: "${escapeYamlString(normalized.npmRegistry)}"\n`
});
files.push({
path: '.bunfig.toml',
content: `[install]\nregistry = "${escapeTomlString(normalized.npmRegistry)}"\n`
});
}
let pypiTrustedHost: string | undefined;
if (normalized.pypiIndexUrl) {
try {
pypiTrustedHost = new URL(normalized.pypiIndexUrl).host || undefined;
} catch {
pypiTrustedHost = undefined;
}
}
const pipConfig = [
'[global]',
...(normalized.pypiIndexUrl ? [`index-url = ${normalized.pypiIndexUrl}`] : []),
...(pypiTrustedHost ? [`trusted-host = ${pypiTrustedHost}`] : [])
];
if (pipConfig.length > 1) {
files.push({
path: '.pip/pip.conf',
content: `${pipConfig.join('\n')}\n`
});
files.push({
path: '.config/pip/pip.conf',
content: `${pipConfig.join('\n')}\n`
});
files.push({
path: '.config/uv/uv.toml',
content: `${[
`default-index = "${escapeTomlString(normalized.pypiIndexUrl!)}"`,
...(pypiTrustedHost
? [`allow-insecure-host = ["${escapeTomlString(pypiTrustedHost)}"]`]
: [])
].join('\n')}\n`
});
}
return files;
};
const normalizeMirrorsConfig = (config: SandboxRuntimeMirrorsConfig): SandboxRuntimeMirrorsConfig =>
Object.fromEntries(
Object.entries(config).flatMap(([key, value]) => {
const trimmed = value?.trim();
return trimmed ? [[key, trimmed]] : [];
})
);
const buildSandboxRuntimeMirrorScript = ({
homeDirectory,
files
}: {
homeDirectory: string;
files: Array<{ path: string; content: string }>;
}) => {
const writeEntries = files.map((file) => ({
path: joinSandboxPath(homeDirectory, file.path),
content: file.content
}));
const prepareDirs = Array.from(
new Set(writeEntries.map((entry) => entry.path.split('/').slice(0, -1).join('/')))
);
return [
`mkdir -p ${prepareDirs.map((dir) => shellQuote(dir)).join(' ')}`,
...writeEntries.map(({ path, content }) => {
const encodedContent = Buffer.from(content, 'utf-8').toString('base64');
return `printf %s ${shellQuote(encodedContent)} | base64 -d > ${shellQuote(path)}`;
})
].join('\n');
};
const escapeTomlString = (value: string) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const escapeYamlString = (value: string) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
import type { ISandbox } from '@fastgpt-sdk/sandbox-adapter';
import {
injectInputFilesToSandbox,
readSandboxPwd,
type SandboxCommandClient,
type SandboxInputFile
} from './files';
import { prepareSandboxRuntimeMirrors } from './mirrors';
import { shellQuote } from './utils';
export type SandboxPrepareContext = {
sandbox: ISandbox;
sandboxClient?: SandboxCommandClient;
workDirectory: string;
currentWorkingDirectory?: string;
};
export type SandboxPrepareStep<Context extends SandboxPrepareContext> = (
context: Context
) => Promise<Context>;
/** 顺序执行 sandbox prepare steps,每个 step 都返回下一步可继续消费的上下文。 */
export const prepareSandbox = async <Context extends SandboxPrepareContext>(
context: Context,
...steps: SandboxPrepareStep<Context>[]
): Promise<Context> => {
let currentContext = context;
for (const step of steps) {
currentContext = await step(currentContext);
}
return currentContext;
};
/** 在 sandbox 内写入 npm/pnpm/yarn/bun/pip/uv 镜像源配置。 */
export const preparePackageMirrors =
<Context extends SandboxPrepareContext>(): SandboxPrepareStep<Context> =>
async (context) => {
await prepareSandboxRuntimeMirrors({ sandbox: context.sandbox });
return context;
};
/** 确保当前 runtime 工作目录存在,供后续文件注入、entrypoint 和 skill 扫描使用。 */
export const prepareWorkDirectory =
<Context extends SandboxPrepareContext>(): SandboxPrepareStep<Context> =>
async (context) => {
const result = await context.sandbox.execute(`mkdir -p ${shellQuote(context.workDirectory)}`);
if (result.exitCode !== 0) {
throw new Error(`Failed to prepare workspace directory: ${result.stderr}`);
}
return context;
};
/** 检查工作目录是否已有内容,用于 edit-debug 复用旧 sandbox 时判断是否需要重新部署包。 */
export const inspectWorkDirectoryContent =
<
Context extends SandboxPrepareContext & { workspaceHasContent?: boolean }
>(): SandboxPrepareStep<Context> =>
async (context) => {
const quotedWorkDirectory = shellQuote(context.workDirectory);
const result = await context.sandbox.execute(
`mkdir -p ${quotedWorkDirectory} && test -n "$(find ${quotedWorkDirectory} -mindepth 1 -print -quit 2>/dev/null)"`
);
if (result.exitCode === 0) {
return {
...context,
workspaceHasContent: true
};
}
if (result.exitCode === 1) {
return {
...context,
workspaceHasContent: false
};
}
throw new Error(`Failed to inspect workspace content: ${result.stderr || result.stdout}`);
};
/** 清空工作目录内容但保留目录本身,避免 volume 根目录权限和挂载点问题。 */
export const emptyWorkDirectory =
<Context extends SandboxPrepareContext>(): SandboxPrepareStep<Context> =>
async (context) => {
// 已有实例重部署时先清空工作区;保留挂载点本身,避免 volume 根目录权限问题。
const cleanCmd = `find ${shellQuote(context.workDirectory)} -mindepth 1 -delete || (rm -rf ${shellQuote(context.workDirectory)}/* && rm -rf ${shellQuote(context.workDirectory)}/.[!.]*)`;
const result = await context.sandbox.execute(cleanCmd);
if (result.exitCode !== 0) {
throw new Error(`Failed to clean workspace processes and files: ${result.stderr}`);
}
return context;
};
/** 将本轮用户输入文件写入 sandbox 的 user_files 目录。 */
export const injectCurrentInputFiles =
<Context extends SandboxPrepareContext>(
currentFiles: SandboxInputFile[]
): SandboxPrepareStep<Context> =>
async (context) => {
await injectInputFilesToSandbox(context.sandbox, currentFiles);
return context;
};
/** 读取 sandbox 当前目录,失败时返回 undefined,由上层决定是否展示提示。 */
export const readCurrentWorkingDirectory =
<
Context extends SandboxPrepareContext & { sandboxClient: SandboxCommandClient }
>(): SandboxPrepareStep<Context> =>
async (context) => ({
...context,
currentWorkingDirectory: await readSandboxPwd(context.sandboxClient)
});
......@@ -8,9 +8,10 @@ const logger = getLogger(LogCategories.MODULE.AI.AGENT);
const RUNTIME_STATE_DIR_RELATIVE_PATH = '.fastgpt/runtime';
const RUNTIME_STATE_FILE_NAME = 'state.json';
type RuntimeStateValue = string | string[];
export type SandboxRuntimeState = {
hashes?: Record<string, string>;
lists?: Record<string, string[]>;
values?: Record<string, RuntimeStateValue>;
};
export type SandboxRuntimeStateContext = {
......@@ -119,38 +120,26 @@ export const writeSandboxRuntimeState = async (
}
};
export const getRuntimeStateHash = (state: SandboxRuntimeState, key: string): string | undefined =>
state.hashes?.[key];
export const getRuntimeStateValue = (state: SandboxRuntimeState, key: string): RuntimeStateValue =>
state.values?.[key] ?? '';
export const setRuntimeStateHash = (
export const setRuntimeStateValue = (
state: SandboxRuntimeState,
key: string,
hash: string
value: RuntimeStateValue
): void => {
state.hashes = {
...(state.hashes ?? {}),
[key]: hash
const normalizedValue = Array.isArray(value) ? Array.from(new Set(value)) : value;
state.values = {
...(state.values ?? {}),
...(Array.isArray(normalizedValue) && normalizedValue.length === 0
? {}
: { [key]: normalizedValue })
};
};
export const getRuntimeStateList = (state: SandboxRuntimeState, key: string): string[] =>
state.lists?.[key] ?? [];
export const setRuntimeStateList = (
state: SandboxRuntimeState,
key: string,
values: string[]
): void => {
const uniqueValues = Array.from(new Set(values));
state.lists = {
...(state.lists ?? {}),
...(uniqueValues.length > 0 ? { [key]: uniqueValues } : {})
};
if (uniqueValues.length === 0) {
delete state.lists[key];
if (Array.isArray(normalizedValue) && normalizedValue.length === 0) {
delete state.values[key];
}
if (Object.keys(state.lists).length === 0) {
delete state.lists;
if (Object.keys(state.values).length === 0) {
delete state.values;
}
};
......@@ -193,13 +182,13 @@ const resolveRuntimeStateLocation = async ({
const normalizeRuntimeState = (value: unknown): SandboxRuntimeState => {
if (!value || typeof value !== 'object') return {};
const raw = value as SandboxRuntimeState;
const hashes = normalizeStringRecord(raw.hashes);
const lists = normalizeStringListRecord(raw.lists);
return {
...(hashes ? { hashes } : {}),
...(lists ? { lists } : {})
const values = {
...normalizeStringRecord((raw as { hashes?: unknown }).hashes),
...normalizeStringListRecord((raw as { lists?: unknown }).lists),
...normalizeRuntimeStateValueRecord(raw.values)
};
return Object.keys(values).length > 0 ? { values } : {};
};
const normalizeStringRecord = (value: unknown): Record<string, string> | undefined => {
......@@ -223,3 +212,25 @@ const normalizeStringListRecord = (value: unknown): Record<string, string[]> | u
});
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
};
const normalizeRuntimeStateValueRecord = (
value: unknown
): Record<string, RuntimeStateValue> | undefined => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return;
const entries: Array<[string, RuntimeStateValue]> = [];
Object.entries(value).forEach(([key, item]) => {
if (typeof item === 'string') {
entries.push([key, item]);
return;
}
if (!Array.isArray(item)) return;
const values = Array.from(new Set(item.filter((value) => typeof value === 'string')));
if (values.length > 0) {
entries.push([key, values]);
}
});
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
};
......@@ -16,3 +16,28 @@ export const joinSandboxPath = (basePath: string, path: string) =>
/** 构建 runtime 状态和 manifest 统一使用的内容 hash。 */
export const buildRuntimeHash = (content: HashContent): string =>
`sha256:${createHash('sha256').update(content).digest('hex')}`;
/**
* 将外部文件名收敛为可写入 sandbox user_files 的单个 path segment。
* URL query、API body 和模型上下文都可能携带文件名,因此调用方不能信任原始 name。
*/
export const getSafeSandboxInputFilename = (
filename: string,
index: number,
usedNames: Map<string, number>
) => {
const fallbackName = `file-${index}`;
const normalized = filename.replace(/\\/g, '/').split('/').pop()?.trim() || fallbackName;
const withoutControlChars = normalized.replace(/[\u0000-\u001F\u007F]/g, '').trim();
const baseName =
withoutControlChars && withoutControlChars !== '.' && withoutControlChars !== '..'
? withoutControlChars
: fallbackName;
const firstDotIndex = baseName.indexOf('.');
const stem = firstDotIndex > 0 ? baseName.slice(0, firstDotIndex) : baseName;
const extension = firstDotIndex > 0 ? baseName.slice(firstDotIndex) : '';
const count = usedNames.get(baseName) || 0;
usedNames.set(baseName, count + 1);
return count === 0 ? baseName : `${stem}-${count}${extension}`;
};
......@@ -11,6 +11,8 @@ import { toolMap as writeFileToolMap } from './writeFile.tool';
import { getSandboxClient, type SandboxClient } from '../service/runtime';
import { parseJsonArgs } from '../../utils';
import { writeUrlFilesToSandbox } from '../service/file';
import { getSandboxRuntimeProfile } from '../runtime/profile';
import { preparePackageMirrors, prepareSandbox } from '../runtime/prepare';
const ToolMap = {
...editFileToolMap,
......@@ -114,6 +116,15 @@ export const prepareSandboxToolRuntime = async ({
files: { path: string; url: string }[];
}) => {
const instance = await getSandboxClient(sandboxId ? { sandboxId } : { appId, userId, chatId });
const runtimeProfile = getSandboxRuntimeProfile();
await prepareSandbox(
{
sandbox: instance.provider,
sandboxClient: instance,
workDirectory: runtimeProfile.workDirectory
},
preparePackageMirrors()
);
await writeUrlFilesToSandbox(instance.provider, files);
return instance;
};
......
......@@ -6,9 +6,9 @@ import type {
import { getSandboxBuiltinSkillsRootPath } from '../../sandbox/runtime/profile/utils';
import { buildRuntimeHash, joinSandboxPath, shellQuote } from '../../sandbox/runtime/utils';
import {
getRuntimeStateHash,
getRuntimeStateValue,
readSandboxRuntimeState,
setRuntimeStateHash,
setRuntimeStateValue,
writeSandboxRuntimeState
} from '../../sandbox/runtime/state';
......@@ -47,7 +47,7 @@ export async function syncBuiltinSkillsToSandbox({
for (const source of syncSources) {
const targetDirectory = joinSandboxPath(builtinSkillsRootPath, source.name);
const stateKey = getBuiltinSkillStateHashKey(source.name);
if (getRuntimeStateHash(runtimeStateContext.state, stateKey) === source.etag) {
if (getRuntimeStateValue(runtimeStateContext.state, stateKey) === source.etag) {
continue;
}
......@@ -69,7 +69,7 @@ export async function syncBuiltinSkillsToSandbox({
throw new Error(`Failed to write builtin skill files: ${failedWrite.error?.message}`);
}
setRuntimeStateHash(runtimeStateContext.state, stateKey, source.etag);
setRuntimeStateValue(runtimeStateContext.state, stateKey, source.etag);
await writeSandboxRuntimeState(sandbox, runtimeStateContext);
}
}
......
......@@ -7,9 +7,9 @@ import {
} from '../../sandbox/runtime/entrypoint';
import { joinSandboxPath, shellQuote } from '../../sandbox/runtime/utils';
import {
getRuntimeStateList,
getRuntimeStateValue,
readSandboxRuntimeState,
setRuntimeStateList,
setRuntimeStateValue,
writeSandboxRuntimeState
} from '../../sandbox/runtime/state';
......@@ -36,14 +36,15 @@ export const runAgentSkillVersionEntrypoints = async ({
const stateContext = await readSandboxRuntimeState({ sandbox });
const state = stateContext.state;
const originalSkillEntrypoints = getRuntimeStateList(state, SKILL_ENTRYPOINT_STATE_LIST_KEY);
const stateValue = getRuntimeStateValue(state, SKILL_ENTRYPOINT_STATE_LIST_KEY);
const originalSkillEntrypoints = Array.isArray(stateValue) ? stateValue : [];
const selectedVersionIds = new Set(versions.map(({ versionId }) => versionId));
const executedVersionIds = new Set(
originalSkillEntrypoints.filter((versionId) => selectedVersionIds.has(versionId))
);
let stateDirty = originalSkillEntrypoints.length !== executedVersionIds.size;
const writeSkillEntrypointState = async () => {
setRuntimeStateList(stateContext.state, SKILL_ENTRYPOINT_STATE_LIST_KEY, [
setRuntimeStateValue(stateContext.state, SKILL_ENTRYPOINT_STATE_LIST_KEY, [
...executedVersionIds
]);
await writeSandboxRuntimeState(sandbox, stateContext);
......
......@@ -2,3 +2,10 @@ export type { DeployedSkillInfo, DeployedSkillVersion } from './types';
export { getAgentSkillInfos, injectAgentSkillFilesToSandbox } from './core';
export { getBuiltinSkillsRootPath, syncBuiltinSkillsToSandbox } from './builtin';
export { runAgentSkillVersionEntrypoints } from './entrypoint';
export {
deployDownloadedSkillPackage,
downloadSkillPackageToContext,
reportSkillPrepareProgress,
type SkillPackagePrepareContext,
type SkillPackagePrepareStep
} from './prepare';
import type { SandboxStatusPhase } from '@fastgpt/global/core/chat/type';
import type { SandboxPrepareContext, SandboxPrepareStep } from '../../sandbox/runtime/prepare';
import { joinSandboxPath, shellQuote } from '../../sandbox/runtime/utils';
import { serviceEnv } from '../../../../env';
import { DEFAULT_GITIGNORE_CONTENT, downloadSkillPackage } from '../package';
export type SkillPackagePrepareContext = SandboxPrepareContext & {
packageBuffer?: Buffer;
workspaceHasContent?: boolean;
};
export type SkillPackagePrepareStep = SandboxPrepareStep<SkillPackagePrepareContext>;
/** 下载指定 skill 版本包,并挂到 prepare context,供后续部署 step 使用。 */
export const downloadSkillPackageToContext =
({
storageKey,
onProgress
}: {
storageKey: string;
onProgress?: (phase: SandboxStatusPhase) => void;
}): SkillPackagePrepareStep =>
async (context) => {
onProgress?.('downloadingPackage');
return {
...context,
packageBuffer: await downloadSkillPackage({ storageKey })
};
};
/** 在 prepare 链路中显式上报 skill 部署阶段,保持调用处生命周期可读。 */
export const reportSkillPrepareProgress =
({
phase,
onProgress
}: {
phase: SandboxStatusPhase;
onProgress?: (phase: SandboxStatusPhase) => void;
}): SkillPackagePrepareStep =>
async (context) => {
onProgress?.(phase);
return context;
};
/** 将已下载的 skill ZIP 写入 sandbox 并解压到当前工作目录。 */
export const deployDownloadedSkillPackage =
({
skillsRootPath,
onProgress
}: {
skillsRootPath: string;
onProgress?: (phase: SandboxStatusPhase) => void;
}): SkillPackagePrepareStep =>
async (context) => {
if (!context.packageBuffer) {
throw new Error('Skill package buffer is required before deployment');
}
onProgress?.('uploadingPackage');
const prepareSkillsRootResult = await context.sandbox.execute(
`mkdir -p ${shellQuote(skillsRootPath)}`
);
if (prepareSkillsRootResult.exitCode !== 0) {
throw new Error(`Failed to prepare skill directory: ${prepareSkillsRootResult.stderr}`);
}
const zipPath = joinSandboxPath(skillsRootPath, 'package.zip');
const maxPackageBytes = serviceEnv.AGENT_SANDBOX_SKILL_MAX_SIZE * 1024 * 1024;
const writeResults = await context.sandbox.writeFiles([
{
path: zipPath,
data: context.packageBuffer
}
]);
const failedWrite = writeResults.find((result) => result.error);
if (failedWrite) {
throw new Error(`Failed to write skill package ZIP: ${failedWrite.error?.message}`);
}
onProgress?.('extractingPackage');
const unzipCmd = [
`cd ${shellQuote(context.workDirectory)}`,
`unzip -Z -t ${shellQuote(zipPath)} | awk -v max=${maxPackageBytes} 'BEGIN { ok=0 } /uncompressed,/ { ok=(($3 + 0) <= max) } END { exit ok ? 0 : 1 }'`,
`unzip -Z1 ${shellQuote(zipPath)} | awk 'BEGIN { ok=1 } /^\\// || /(^|\\/)\\.\\.($|\\/)/ { ok=0 } END { exit ok ? 0 : 1 }'`,
`unzip -o -q ${shellQuote(zipPath)} -d .`,
`rm -f ${shellQuote(zipPath)}`,
`if [ ! -f .gitignore ]; then echo ${shellQuote(DEFAULT_GITIGNORE_CONTENT)} > .gitignore; fi`
].join(' && ');
const extractResult = await context.sandbox.execute(unzipCmd);
if (extractResult.exitCode !== 0) {
throw new Error(`Failed to decompress package inside sandbox: ${extractResult.stderr}`);
}
return context;
};
/**
* 将外部文件名收敛为可写入 sandbox user_files 的单个 path segment。
* URL query 和 API body 都可能携带文件名,因此调用方不能信任原始 name。
*/
export const getSafeAgentInputFilename = (
filename: string,
index: number,
usedNames: Map<string, number>
) => {
const fallbackName = `file-${index}`;
const normalized = filename.replace(/\\/g, '/').split('/').pop()?.trim() || fallbackName;
const withoutControlChars = normalized.replace(/[\u0000-\u001F\u007F]/g, '').trim();
const baseName =
withoutControlChars && withoutControlChars !== '.' && withoutControlChars !== '..'
? withoutControlChars
: fallbackName;
const firstDotIndex = baseName.indexOf('.');
const stem = firstDotIndex > 0 ? baseName.slice(0, firstDotIndex) : baseName;
const extension = firstDotIndex > 0 ? baseName.slice(firstDotIndex) : '';
const count = usedNames.get(baseName) || 0;
usedNames.set(baseName, count + 1);
return count === 0 ? baseName : `${stem}-${count}${extension}`;
};
......@@ -12,7 +12,7 @@ import type { DeployedSkillInfo } from '../../../../../ai/skill/runtime/types';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { SubAppIds } from '@fastgpt/global/core/workflow/node/agent/constants';
import { SANDBOX_READ_FILE_TOOL_NAME } from '@fastgpt/global/core/ai/sandbox/tools';
import { getSafeAgentInputFilename } from './fileName';
import { getSafeSandboxInputFilename } from '../../../../../ai/sandbox/runtime/utils';
export type AgentInputFile = {
id: string;
......@@ -122,7 +122,7 @@ export function parseAgentInputFiles({
return {
id: `${prefixId}-${index}`,
name: getSafeAgentInputFilename(file.name || parsedFile.name || url, index, usedNames),
name: getSafeSandboxInputFilename(file.name || parsedFile.name || url, index, usedNames),
type: parsedFile.type,
url: parsedFile.url
};
......
......@@ -11,19 +11,21 @@ import {
import { prepareAgentSandboxRuntime } from '../../../../../../ai/sandbox/runtime';
import type { SandboxClient } from '../../../../../../ai/sandbox/service/runtime';
import {
injectInputFilesToSandbox,
readSandboxPwd
} from '../../../../../../ai/sandbox/runtime/files';
injectCurrentInputFiles,
preparePackageMirrors,
prepareSandbox,
readCurrentWorkingDirectory,
type SandboxPrepareContext,
type SandboxPrepareStep
} from '../../../../../../ai/sandbox/runtime/prepare';
import {
runAgentSandboxEntrypoint,
runSandboxEntrypoint,
withAgentSandboxInitLease
} from '../../../../../../ai/sandbox/runtime/entrypoint';
import { resolveSandboxHome } from '../../../../../../ai/sandbox/runtime/home';
export type AgentSandboxPrepareContext = {
export type AgentSandboxPrepareContext = SandboxPrepareContext & {
sandboxClient: SandboxClient;
workDirectory: string;
currentWorkingDirectory?: string;
deployedSkillVersions: DeployedSkillVersion[];
skillInfos: DeployedSkillInfo[];
skillScanDirectories: string[];
......@@ -52,9 +54,7 @@ type EnsureAgentSandboxRuntimeResult = {
currentWorkingDirectory?: string;
skillInfos: DeployedSkillInfo[];
};
type AgentSandboxPrepareStep = (
context: AgentSandboxPrepareContext
) => Promise<AgentSandboxPrepareContext>;
type AgentSandboxPrepareStep = SandboxPrepareStep<AgentSandboxPrepareContext>;
/**
* 确保 Agent 本轮 sandbox runtime 可用。
......@@ -75,25 +75,26 @@ export async function ensureAgentSandboxRuntime({
prepareActions = [],
currentFiles
}: EnsureAgentSandboxRuntimeParams): Promise<EnsureAgentSandboxRuntimeResult> {
if (!needSandboxRuntime) {
return {
skillInfos: []
};
}
const sandboxContext = await prepareAgentSandboxRuntime({
appId,
userId,
chatId,
sandboxId,
teamId,
needSandboxRuntime
teamId
});
if (!sandboxContext) {
return {
skillInfos: []
};
}
const preparedContext = await withAgentSandboxInitLease({
sandboxId: sandboxContext.sandboxClient.getSandboxId(),
fn: () => {
const context = {
...sandboxContext,
sandbox: sandboxContext.sandboxClient.provider,
deployedSkillVersions: [],
skillInfos: [],
skillScanDirectories: []
......@@ -102,6 +103,7 @@ export async function ensureAgentSandboxRuntime({
return editSkillId
? prepareSandbox(
context,
preparePackageMirrors(),
injectCurrentInputFiles(currentFiles),
...prepareActions,
readCurrentWorkingDirectory(),
......@@ -109,6 +111,7 @@ export async function ensureAgentSandboxRuntime({
)
: prepareSandbox(
context,
preparePackageMirrors(),
injectSelectedSkillFiles({ teamId, tmbId, skillIds }),
injectCurrentInputFiles(currentFiles),
...prepareActions,
......@@ -127,29 +130,6 @@ export async function ensureAgentSandboxRuntime({
};
}
const prepareSandbox = async (
context: AgentSandboxPrepareContext,
...steps: AgentSandboxPrepareStep[]
): Promise<AgentSandboxPrepareContext> => {
let currentContext = context;
for (const step of steps) {
currentContext = await step(currentContext);
}
return currentContext;
};
const injectCurrentInputFiles =
(currentFiles: AgentInputFile[]): AgentSandboxPrepareStep =>
async (context) => {
await injectInputFilesToSandbox(context.sandboxClient.provider, currentFiles);
return context;
};
const readCurrentWorkingDirectory = (): AgentSandboxPrepareStep => async (context) => ({
...context,
currentWorkingDirectory: await readSandboxPwd(context.sandboxClient)
});
/**
* 创建“同步内置 Skill 到当前 sandbox”的 prepare action。
*
......@@ -168,13 +148,13 @@ export const createBuiltinSkillPrepareAction =
const sources = await getSources();
if (sources.length === 0) return context;
const homeDirectory = await resolveSandboxHome(context.sandboxClient.provider);
const homeDirectory = await resolveSandboxHome(context.sandbox);
if (!homeDirectory) {
throw new Error('Failed to resolve sandbox HOME for builtin skill sync');
}
await injectToSandbox({
sandbox: context.sandboxClient.provider,
sandbox: context.sandbox,
homeDirectory,
sources
});
......@@ -193,7 +173,7 @@ export const createBuiltinSkillPrepareAction =
const scanEditDebugSkillInfos = (): AgentSandboxPrepareStep => async (context) => ({
...context,
skillInfos: await getAgentSkillInfos({
sandbox: context.sandboxClient.provider,
sandbox: context.sandbox,
skillDirectories: [context.workDirectory, ...context.skillScanDirectories]
})
});
......@@ -211,7 +191,7 @@ const injectSelectedSkillFiles =
async (context) => ({
...context,
deployedSkillVersions: await injectAgentSkillFilesToSandbox({
sandbox: context.sandboxClient.provider,
sandbox: context.sandbox,
teamId,
tmbId,
skillIds,
......@@ -219,21 +199,10 @@ const injectSelectedSkillFiles =
})
});
const runSandboxEntrypoint =
({ sandboxEntrypoint }: { sandboxEntrypoint?: string }): AgentSandboxPrepareStep =>
async (context) => {
await runAgentSandboxEntrypoint({
sandbox: context.sandboxClient.provider,
sandboxEntrypoint,
workDirectory: context.workDirectory
});
return context;
};
const runSelectedSkillEntrypoints = (): AgentSandboxPrepareStep => async (context) => {
if (context.deployedSkillVersions.length > 0) {
await runAgentSkillVersionEntrypoints({
sandbox: context.sandboxClient.provider,
sandbox: context.sandbox,
versions: context.deployedSkillVersions
});
}
......@@ -249,7 +218,7 @@ const scanSelectedSkillInfos = (): AgentSandboxPrepareStep => async (context) =>
];
return skillDirectories.length > 0
? getAgentSkillInfos({
sandbox: context.sandboxClient.provider,
sandbox: context.sandbox,
skillDirectories
})
: Promise.resolve([]);
......
......@@ -10,7 +10,6 @@ const defaultableIntSchema = (defaultValue: number) =>
(value) => (value === '' || value === undefined ? defaultValue : value),
z.coerce.number<number>().int().nonnegative()
);
// 系统最大字符串处理长度
const SYSTEM_STRING_LENGTH_UNIT = 1_000_000;
......@@ -108,6 +107,8 @@ export const serviceEnv = createEnv({
AGENT_SANDBOX_ENTRYPOINT_TIMEOUT_SECONDS: IntSchema.min(1).max(600).default(30).meta({
description: 'Agent sandbox entrypoint 执行超时时间(秒)'
}),
AGENT_SANDBOX_NPM_REGISTRY: z.string().optional(),
AGENT_SANDBOX_PYPI_INDEX_URL: z.string().optional(),
// ==================== 数据库与缓存 ====================
// Redisg
......
import { describe, expect, it, vi } from 'vitest';
import { prepareSandboxRuntimeMirrors } from '@fastgpt/service/core/ai/sandbox/runtime/mirrors';
import { buildRuntimeHash } from '@fastgpt/service/core/ai/sandbox/runtime/utils';
const createSandbox = () => {
let stateContent: string | undefined;
const executedCommands: string[] = [];
const sandbox = {
execute: vi.fn(async (command: string) => {
if (command === 'printf "%s" "$HOME"') {
return { exitCode: 0, stdout: '/home/test', stderr: '' };
}
if (command.startsWith("mkdir -p '/home/test/.fastgpt/runtime'")) {
return { exitCode: 0, stdout: '', stderr: '' };
}
if (command.includes("base64 -d > '/home/test/")) {
executedCommands.push(command);
return { exitCode: 0, stdout: '', stderr: '' };
}
throw new Error(`Unexpected command: ${command}`);
}),
readFiles: vi.fn(async (paths: string[]) =>
paths.map((path) => ({
path,
content: Buffer.from(stateContent || ''),
error: stateContent ? null : new Error('not found')
}))
),
writeFiles: vi.fn(async (entries: Array<{ path: string; data: string }>) => {
const stateEntry = entries.find((entry) =>
entry.path.endsWith('/.fastgpt/runtime/state.json')
);
if (stateEntry) {
stateContent = stateEntry.data;
}
return entries.map((entry) => ({
path: entry.path,
bytesWritten: entry.data.length,
error: null
}));
}),
getExecutedCommands: () => executedCommands,
getState: () => (stateContent ? JSON.parse(stateContent) : undefined)
};
return sandbox;
};
describe('sandbox runtime mirrors', () => {
it('executes npm, yarn, pnpm, bun, pip and uv mirror script once per hash', async () => {
const sandbox = createSandbox();
const expectedMirrorFiles = [
{
path: '.npmrc',
content: 'registry=https://npm.example.com\n'
},
{
path: '.yarnrc',
content: 'registry "https://npm.example.com"\n'
},
{
path: '.yarnrc.yml',
content: 'npmRegistryServer: "https://npm.example.com"\n'
},
{
path: '.bunfig.toml',
content: '[install]\nregistry = "https://npm.example.com"\n'
},
{
path: '.pip/pip.conf',
content:
'[global]\nindex-url = https://pypi.example.com/simple\ntrusted-host = pypi.example.com\n'
},
{
path: '.config/pip/pip.conf',
content:
'[global]\nindex-url = https://pypi.example.com/simple\ntrusted-host = pypi.example.com\n'
},
{
path: '.config/uv/uv.toml',
content:
'default-index = "https://pypi.example.com/simple"\nallow-insecure-host = ["pypi.example.com"]\n'
}
];
const expectedScript = [
"mkdir -p '/home/test' '/home/test/.pip' '/home/test/.config/pip' '/home/test/.config/uv'",
...expectedMirrorFiles.map(({ path, content }) => {
const encodedContent = Buffer.from(content, 'utf-8').toString('base64');
return `printf %s '${encodedContent}' | base64 -d > '/home/test/${path}'`;
})
].join('\n');
await prepareSandboxRuntimeMirrors({
sandbox: sandbox as any,
config: {
npmRegistry: 'https://npm.example.com',
pypiIndexUrl: 'https://pypi.example.com/simple'
}
});
await prepareSandboxRuntimeMirrors({
sandbox: sandbox as any,
config: {
npmRegistry: 'https://npm.example.com',
pypiIndexUrl: 'https://pypi.example.com/simple'
}
});
expect(sandbox.getExecutedCommands()).toEqual([expectedScript]);
expect(sandbox.getState()?.values?.sandboxPackageMirrors).toBe(
buildRuntimeHash(expectedScript)
);
});
});
......@@ -13,6 +13,10 @@ const fileServiceMock = vi.hoisted(() => ({
writeUrlFilesToSandbox: vi.fn()
}));
const mirrorMock = vi.hoisted(() => ({
prepareSandboxRuntimeMirrors: vi.fn()
}));
const s3Mock = vi.hoisted(() => ({
uploadChatFile: vi.fn(),
jwtSignS3ObjectKey: vi.fn()
......@@ -26,6 +30,10 @@ vi.mock('@fastgpt/service/core/ai/sandbox/service/file', () => ({
writeUrlFilesToSandbox: fileServiceMock.writeUrlFilesToSandbox
}));
vi.mock('@fastgpt/service/core/ai/sandbox/runtime/mirrors', () => ({
prepareSandboxRuntimeMirrors: mirrorMock.prepareSandboxRuntimeMirrors
}));
vi.mock('@fastgpt/service/common/s3/sources/chat', () => ({
getS3ChatSource: () => ({
uploadChatFile: s3Mock.uploadChatFile
......@@ -147,6 +155,12 @@ describe('sandbox toolCall index', () => {
).resolves.toBe(sandbox);
expect(sandbox.ensureAvailable).not.toHaveBeenCalled();
expect(mirrorMock.prepareSandboxRuntimeMirrors).toHaveBeenCalledWith({
sandbox: sandbox.provider
});
expect(mirrorMock.prepareSandboxRuntimeMirrors.mock.invocationCallOrder[0]).toBeLessThan(
fileServiceMock.writeUrlFilesToSandbox.mock.invocationCallOrder[0]
);
expect(fileServiceMock.writeUrlFilesToSandbox).toHaveBeenCalledWith(sandbox.provider, files);
});
......
......@@ -62,12 +62,13 @@ describe('builtin skill runtime', () => {
expect(sandbox.writeFiles.mock.calls[1][0]).toEqual([
expect.objectContaining({
path: '/home/sandbox/.fastgpt/runtime/state.json',
data: expect.stringContaining('builtinSkill:skill-creator')
data: expect.stringContaining('"values"')
})
]);
expect(sandbox.writeFiles.mock.calls[1][0][0].data).toContain('builtinSkill:skill-creator');
});
it('skips writing builtin skill when runtime state etag is current', async () => {
it('skips writing builtin skill when legacy runtime hash state is current', async () => {
const sources = [
{
name: 'skill-creator',
......
......@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
connectToSandbox: vi.fn(),
disconnectSandbox: vi.fn(),
deleteWorkspaceArchive: vi.fn(),
prepareSandboxRuntimeMirrors: vi.fn(),
logger: {
info: vi.fn(),
warn: vi.fn(),
......@@ -93,6 +94,10 @@ vi.mock('@fastgpt/service/core/ai/sandbox/service/resource', () => ({
deleteSandboxResource: vi.fn()
}));
vi.mock('@fastgpt/service/core/ai/sandbox/runtime/mirrors', () => ({
prepareSandboxRuntimeMirrors: mocks.prepareSandboxRuntimeMirrors
}));
vi.mock('@fastgpt/service/common/s3/sources/sandbox', () => ({
getS3SandboxSource: () => ({
deleteWorkspaceArchive: mocks.deleteWorkspaceArchive
......@@ -108,18 +113,6 @@ vi.mock('@fastgpt/service/core/ai/sandbox/instance/repository', () => ({
updateSandboxInstanceRecordBySandboxId: vi.fn()
}));
vi.mock('@fastgpt/service/core/ai/skill/runtime', () => {
const trimSandboxPathRight = (value: string) => (value === '/' ? '' : value.replace(/\/+$/, ''));
return {
getSkillsRootPath: (workDirectory: string) => `${trimSandboxPathRight(workDirectory)}/skills`,
joinSandboxPath: (basePath: string, path: string) =>
`${trimSandboxPathRight(basePath)}/${path}`,
shellQuote: (value: string) => `'${value.replace(/'/g, `'\\''`)}'`,
getSafeSkillDirectoryName: (name: string) => name
};
});
vi.mock('@fastgpt/service/common/logger', () => ({
getLogger: () => mocks.logger,
LogCategories: {
......@@ -293,12 +286,8 @@ describe('packageSkillInSandbox', () => {
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
`;
it('applies default ignore rules and root workspace gitignore when packaging', async () => {
const gitignoreContent = 'dist/\n';
const zipContent = new Uint8Array([9, 8, 7]);
const sandbox = {
execute: vi.fn(async (command: string) => {
......@@ -332,80 +321,14 @@ temp_data.csv
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'"));
});
it('applies default ignore rules when packaging without workspace gitignore', async () => {
const zipContent = new Uint8Array([1, 2, 3]);
const sandbox = createSandbox({
readFilesResult: [
{
path: '/workspace/package.zip',
content: zipContent,
error: null
}
]
});
mocks.connectToSandbox.mockResolvedValueOnce(sandbox);
await expect(packageSkillInSandbox({ sandboxId: 'sandbox-1' })).resolves.toEqual(
Buffer.from(zipContent)
);
expect(sandbox.execute).toHaveBeenCalledWith(
expect.stringContaining("-name '.venv' -o -name 'node_modules'")
);
expect(sandbox.execute).toHaveBeenCalledWith(expect.stringContaining("-x '.venv/*'"));
expect(sandbox.execute).toHaveBeenCalledWith(expect.stringContaining("-x '*/.venv/*'"));
});
it('only reads root gitignore when packaging', async () => {
const rootGitignoreContent = 'dist/\n';
const zipContent = new Uint8Array([1, 2, 3]);
const sandbox = {
execute: vi.fn(async (command: string) => {
if (command.startsWith('[ -d ')) {
return { exitCode: 0, stdout: '', 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/package.zip')) {
return [{ path: '/workspace/package.zip', content: zipContent, error: null }];
}
if (paths.includes('/workspace/.gitignore')) {
return [{ path: '/workspace/.gitignore', content: rootGitignoreContent, error: null }];
}
return [];
})
};
mocks.connectToSandbox.mockResolvedValueOnce(sandbox);
await expect(packageSkillInSandbox({ sandboxId: 'sandbox-1' })).resolves.toEqual(
Buffer.from(zipContent)
);
expect(sandbox.readFiles).toHaveBeenCalledWith(['/workspace/.gitignore']);
expect(sandbox.readFiles).not.toHaveBeenCalledWith(['/workspace/.venv/.gitignore']);
expect(sandbox.execute).toHaveBeenCalledWith(expect.stringContaining("-x 'dist/*'"));
expect(sandbox.execute).toHaveBeenCalledWith(expect.stringContaining("-x '*/dist/*'"));
});
});
......@@ -513,6 +436,9 @@ describe('createEditDebugSandbox', () => {
]);
expect(provider.execute).toHaveBeenCalledWith("mkdir -p '/workspace/skills'");
expect(provider.execute).toHaveBeenCalledWith(expect.stringContaining('unzip'));
expect(mocks.prepareSandboxRuntimeMirrors).toHaveBeenCalledWith({
sandbox: provider
});
expect(mocks.disconnectSandbox).toHaveBeenCalledWith(provider);
});
......
......@@ -93,7 +93,7 @@ describe('runtime entrypoint', () => {
sandbox: sandbox as any,
sandboxEntrypoint: 'echo first'
});
const firstHash = sandbox.getState()?.hashes?.sandboxEntrypoint;
const firstHash = sandbox.getState()?.values?.sandboxEntrypoint;
await runAgentSandboxEntrypoint({
sandbox: sandbox as any,
......@@ -109,8 +109,8 @@ describe('runtime entrypoint', () => {
.filter(isSandboxEntrypointCommand);
expect(entrypointCommands).toHaveLength(2);
expect(sandbox.getState()?.hashes?.sandboxEntrypoint).toMatch(/^sha256:/);
expect(sandbox.getState()?.hashes?.sandboxEntrypoint).not.toBe(firstHash);
expect(sandbox.getState()?.values?.sandboxEntrypoint).toMatch(/^sha256:/);
expect(sandbox.getState()?.values?.sandboxEntrypoint).not.toBe(firstHash);
});
it('runs sandbox entrypoint from the configured work directory', async () => {
......@@ -128,27 +128,25 @@ describe('runtime entrypoint', () => {
expect(entrypointCommand).toMatch(/^cd '\/workspace' && \/bin\/bash -c /);
});
it('does not write sandbox entrypoint state when execution fails', async () => {
const sandbox = createSandbox({ entrypointExitCode: 1 });
it('does not write sandbox entrypoint state when execution fails or throws', async () => {
const failedSandbox = createSandbox({ entrypointExitCode: 1 });
await runAgentSandboxEntrypoint({
sandbox: sandbox as any,
sandbox: failedSandbox as any,
sandboxEntrypoint: 'exit 1'
});
expect(sandbox.getState()?.hashes?.sandboxEntrypoint).toBeUndefined();
});
it('does not throw or write state when sandbox entrypoint execution throws', async () => {
const sandbox = createSandbox({ entrypointThrows: true });
const throwingSandbox = createSandbox({ entrypointThrows: true });
await expect(
runAgentSandboxEntrypoint({
sandbox: sandbox as any,
sandbox: throwingSandbox as any,
sandboxEntrypoint: 'echo throw'
})
).resolves.toBeUndefined();
expect(sandbox.getState()?.hashes?.sandboxEntrypoint).toBeUndefined();
expect(failedSandbox.getState()?.values?.sandboxEntrypoint).toBeUndefined();
expect(throwingSandbox.getState()?.values?.sandboxEntrypoint).toBeUndefined();
});
it('uses skill version state to skip successful skill entrypoints', async () => {
......@@ -168,7 +166,7 @@ describe('runtime entrypoint', () => {
.filter(isSkillEntrypointCommand);
expect(runCommands).toHaveLength(1);
expect(sandbox.getState()?.lists?.skillEntrypoints).toEqual(['version-1']);
expect(sandbox.getState()?.values?.skillEntrypoints).toEqual(['version-1']);
});
it('retries skill entrypoint after a failed run', async () => {
......@@ -188,6 +186,6 @@ describe('runtime entrypoint', () => {
.filter(isSkillEntrypointCommand);
expect(runCommands).toHaveLength(2);
expect(sandbox.getState()?.lists?.skillEntrypoints).toBeUndefined();
expect(sandbox.getState()?.values?.skillEntrypoints).toBeUndefined();
});
});
......@@ -5,6 +5,7 @@ const {
prepareAgentSandboxRuntimeMock,
withAgentSandboxInitLeaseMock,
injectInputFilesToSandboxMock,
prepareSandboxRuntimeMirrorsMock,
readSandboxPwdMock,
runAgentSandboxEntrypointMock,
resolveSandboxHomeMock,
......@@ -18,6 +19,7 @@ const {
prepareAgentSandboxRuntimeMock: vi.fn(),
withAgentSandboxInitLeaseMock: vi.fn(async ({ fn }: { fn: () => Promise<unknown> }) => fn()),
injectInputFilesToSandboxMock: vi.fn(),
prepareSandboxRuntimeMirrorsMock: vi.fn(),
readSandboxPwdMock: vi.fn(),
runAgentSandboxEntrypointMock: vi.fn(),
resolveSandboxHomeMock: vi.fn(),
......@@ -45,6 +47,16 @@ vi.mock('@fastgpt/service/core/ai/sandbox/runtime/files', () => ({
vi.mock('@fastgpt/service/core/ai/sandbox/runtime/entrypoint', () => ({
runAgentSandboxEntrypoint: runAgentSandboxEntrypointMock,
runSandboxEntrypoint:
({ sandboxEntrypoint }: { sandboxEntrypoint?: string }) =>
async (context: { sandbox: unknown; workDirectory: string }) => {
await runAgentSandboxEntrypointMock({
sandbox: context.sandbox,
sandboxEntrypoint,
workDirectory: context.workDirectory
});
return context;
},
withAgentSandboxInitLease: withAgentSandboxInitLeaseMock
}));
......@@ -52,6 +64,10 @@ vi.mock('@fastgpt/service/core/ai/sandbox/runtime/home', () => ({
resolveSandboxHome: resolveSandboxHomeMock
}));
vi.mock('@fastgpt/service/core/ai/sandbox/runtime/mirrors', () => ({
prepareSandboxRuntimeMirrors: prepareSandboxRuntimeMirrorsMock
}));
vi.mock('@fastgpt/service/core/ai/skill/runtime', () => ({
getAgentSkillInfos: getAgentSkillInfosMock,
getBuiltinSkillsRootPath: (homeDirectory: string) => `${homeDirectory}/.fastgpt/skills`,
......@@ -98,6 +114,10 @@ describe('ensureAgentSandboxRuntime', () => {
it('runs selected skill lifecycle inside sandbox init lease', async () => {
const { ensureAgentSandboxRuntime } =
await import('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/sandbox/prepare');
const prepareAction = vi.fn(async (context) => ({
...context,
skillScanDirectories: [...context.skillScanDirectories, '/home/sandbox/.fastgpt/skills']
}));
const result = await ensureAgentSandboxRuntime({
appId: 'app_1',
......@@ -108,6 +128,7 @@ describe('ensureAgentSandboxRuntime', () => {
needSandboxRuntime: true,
sandboxEntrypoint: 'pip install -r requirements.txt',
skillIds: ['skill_1'],
prepareActions: [prepareAction],
currentFiles
});
......@@ -122,6 +143,15 @@ describe('ensureAgentSandboxRuntime', () => {
skillIds: ['skill_1'],
workDirectory: '/workspace'
});
expect(prepareSandboxRuntimeMirrorsMock).toHaveBeenCalledWith({
sandbox: sandboxProviderMock
});
expect(prepareSandboxRuntimeMirrorsMock.mock.invocationCallOrder[0]).toBeLessThan(
injectAgentSkillFilesToSandboxMock.mock.invocationCallOrder[0]
);
expect(prepareSandboxRuntimeMirrorsMock.mock.invocationCallOrder[0]).toBeLessThan(
runAgentSandboxEntrypointMock.mock.invocationCallOrder[0]
);
expect(injectInputFilesToSandboxMock).toHaveBeenCalledWith(sandboxProviderMock, currentFiles);
expect(readSandboxPwdMock).toHaveBeenCalledWith(sandboxClientMock);
expect(runAgentSandboxEntrypointMock).toHaveBeenCalledWith({
......@@ -138,9 +168,20 @@ describe('ensureAgentSandboxRuntime', () => {
}
]
});
expect(prepareAction).toHaveBeenCalledWith(
expect.objectContaining({
deployedSkillVersions: [
{
versionId: 'version_1',
targetDir: '/workspace/skills/version_1'
}
],
skillScanDirectories: []
})
);
expect(getAgentSkillInfosMock).toHaveBeenCalledWith({
sandbox: sandboxProviderMock,
skillDirectories: ['/workspace/skills/version_1']
skillDirectories: ['/workspace/skills/version_1', '/home/sandbox/.fastgpt/skills']
});
expect(result).toEqual({
sandboxClient: sandboxClientMock,
......@@ -157,7 +198,7 @@ describe('ensureAgentSandboxRuntime', () => {
});
});
it('runs custom prepare actions in selected skill lifecycle', async () => {
it('runs edit-debug lifecycle without deploying selected skills or builtin skills by default', async () => {
const { ensureAgentSandboxRuntime } =
await import('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/sandbox/prepare');
const prepareAction = vi.fn(async (context) => ({
......@@ -170,76 +211,22 @@ describe('ensureAgentSandboxRuntime', () => {
userId: 'user_1',
chatId: 'chat_1',
teamId: 'team_1',
needSandboxRuntime: true,
skillIds: ['skill_1'],
prepareActions: [prepareAction],
currentFiles
});
expect(prepareAction).toHaveBeenCalledWith(
expect.objectContaining({
deployedSkillVersions: [
{
versionId: 'version_1',
targetDir: '/workspace/skills/version_1'
}
],
skillScanDirectories: []
})
);
expect(getAgentSkillInfosMock).toHaveBeenCalledWith({
sandbox: sandboxProviderMock,
skillDirectories: ['/workspace/skills/version_1', '/home/sandbox/.fastgpt/skills']
});
});
it('runs edit-debug lifecycle without deploying selected skills or builtin skills by default', async () => {
const { ensureAgentSandboxRuntime } =
await import('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/sandbox/prepare');
await ensureAgentSandboxRuntime({
appId: 'app_1',
userId: 'user_1',
chatId: 'chat_1',
teamId: 'team_1',
tmbId: 'tmb_1',
needSandboxRuntime: true,
skillIds: [],
editSkillId: 'edit_skill_1',
prepareActions: [prepareAction],
currentFiles
});
expect(prepareSandboxRuntimeMirrorsMock).toHaveBeenCalledWith({
sandbox: sandboxProviderMock
});
expect(prepareSandboxRuntimeMirrorsMock.mock.invocationCallOrder[0]).toBeLessThan(
injectInputFilesToSandboxMock.mock.invocationCallOrder[0]
);
expect(injectInputFilesToSandboxMock).toHaveBeenCalledWith(sandboxProviderMock, currentFiles);
expect(syncBuiltinSkillsToSandboxMock).not.toHaveBeenCalled();
expect(getAgentSkillInfosMock).toHaveBeenCalledWith({
sandbox: sandboxProviderMock,
skillDirectories: ['/workspace']
});
expect(injectAgentSkillFilesToSandboxMock).not.toHaveBeenCalled();
expect(runAgentSandboxEntrypointMock).not.toHaveBeenCalled();
expect(runAgentSkillVersionEntrypointsMock).not.toHaveBeenCalled();
});
it('runs custom prepare actions in edit-debug lifecycle', async () => {
const { ensureAgentSandboxRuntime } =
await import('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/sandbox/prepare');
const prepareAction = vi.fn(async (context) => ({
...context,
skillScanDirectories: [...context.skillScanDirectories, '/home/sandbox/.fastgpt/skills']
}));
await ensureAgentSandboxRuntime({
appId: 'app_1',
userId: 'user_1',
chatId: 'chat_1',
teamId: 'team_1',
needSandboxRuntime: true,
skillIds: [],
editSkillId: 'edit_skill_1',
prepareActions: [prepareAction],
currentFiles
});
expect(prepareAction).toHaveBeenCalledWith(
expect.objectContaining({
sandboxClient: sandboxClientMock,
......@@ -251,6 +238,9 @@ describe('ensureAgentSandboxRuntime', () => {
sandbox: sandboxProviderMock,
skillDirectories: ['/workspace', '/home/sandbox/.fastgpt/skills']
});
expect(injectAgentSkillFilesToSandboxMock).not.toHaveBeenCalled();
expect(runAgentSandboxEntrypointMock).not.toHaveBeenCalled();
expect(runAgentSkillVersionEntrypointsMock).not.toHaveBeenCalled();
});
it('creates builtin skill prepare action with lazy source loading', async () => {
......@@ -270,6 +260,7 @@ describe('ensureAgentSandboxRuntime', () => {
const getSources = vi.fn(async () => builtinSkillSources);
const result = await createBuiltinSkillPrepareAction({ getSources })({
sandbox: sandboxProviderMock,
sandboxClient: sandboxClientMock,
workDirectory: '/workspace',
deployedSkillVersions: [],
......@@ -290,7 +281,6 @@ describe('ensureAgentSandboxRuntime', () => {
it('returns empty skill infos when sandbox runtime is not needed', async () => {
const { ensureAgentSandboxRuntime } =
await import('@fastgpt/service/core/workflow/dispatch/ai/agent/sub/sandbox/prepare');
prepareAgentSandboxRuntimeMock.mockResolvedValueOnce(undefined);
await expect(
ensureAgentSandboxRuntime({
......@@ -306,6 +296,7 @@ describe('ensureAgentSandboxRuntime', () => {
).resolves.toEqual({
skillInfos: []
});
expect(prepareAgentSandboxRuntimeMock).not.toHaveBeenCalled();
expect(withAgentSandboxInitLeaseMock).not.toHaveBeenCalled();
});
});
......@@ -68,6 +68,10 @@ AGENT_SANDBOX_MAX_FILE_SIZE=10
AGENT_SANDBOX_ARCHIVE_MAX_SIZE=50
# Skill sandbox 包大小上限(MB),用于 Skill 包上传、下载和打包发布校验
AGENT_SANDBOX_SKILL_MAX_SIZE=10
# Agent 沙箱内 npm/yarn/pnpm/bun 使用的 npm registry(可选)
AGENT_SANDBOX_NPM_REGISTRY=
# Agent 沙箱内 pip/python -m pip/uv 使用的 PyPI index URL(可选)
AGENT_SANDBOX_PYPI_INDEX_URL=
# ==================== 沙盒代理 (agent-sandbox-proxy) 与网络配置 ====================
# JWT 验签与内网安全物理阻断密钥 (必须与 Rust Proxy 的 AGENT_SANDBOX_PROXY_SECRET 环境变量保持完全一致)
......
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