Commit 0b86c361 by DigHuang Committed by GitHub

feat(sandbox): migrate to standalone Rust proxy and implement stateless WSS…

feat(sandbox): migrate to standalone Rust proxy and implement stateless WSS gateway with dynamic file sync (#7034)

* feat(sandbox): implement stateless WSS gateway, Rust agent-proxy service, and interactive terminal

- Implement stateless WSS gateway for agent-sandbox connection.
- Migrate WSS proxy to a standalone Rust service.
- Integrate interactive PTY terminal and clean up obsolete APIs.
- Add unit & integration tests for Rust proxy & agent with CI workflow.
- Unify proxy secrets under AGENT_SANDBOX_PROXY_SECRET.
- Implement fine-grained session ticket permissions & enhance proxy log security.

* refactor(sandbox): upgrade agent & proxy to Rust 2024, optimize file operations, and support connection limiting

- Implement dynamic file change sync with debounce and robust connection limiting.
- Upgrade Rust dependencies, split ide agent modules and enhance file access security.
- Optimize Docker build targets, configure default Sealos image, and implement native file operations.
- Support dynamic gateway hostnames, add proxy ping keepalive, and refine terminal resize checks.
- Upgrade to Rust 2024, secure workspace operations, and refine adapter helpers.

* feat(skill): redesign skill detail page with split layout and refine sandbox editor styling

* refactor(sandbox): optimize edit-debug hot-reload and skip S3 pull on cold-start

- Refine edit-debug sandbox hot-reload and auto resume logic.
- Skip S3 pull on cold-start when volume exists and fix OpenSandbox getInfo.

* feat(sandbox): implement cold archive and restore, unify file limits, and relax proxy secret

* style(skill): adjust chatbox and input layout padding for skill detail page

* feat(sandbox): add archive migration API and restore   support for empty or archived workspaces

refactor(sandbox): enhance workspace archiving/restore, zip safety, and websocket stability

* feat(sandbox): migrate archived records across providers, adjust probe timeout and command limits

* feat(skill): add preview empty state, disable sending before sandbox ready, and unify error handling

* refactor(skill): simplify export, fix import encoding, and use default LLM

* feat(sandbox): ensure zip availability and track archiving failures

* feat(sandbox): refactor workspace fs watcher with debouncer, improve terminal styling, and add archive progress tracking

- rust ide agent: Integrate notify-debouncer-full to debounce workspace file system events and broadcast batches with sequential numbers to handle lagged events.
- frontend/sandbox: Remove client-side debounce in favor of backend debouncing, refine workspace file tree refreshing, and adjust terminal background and horizontal padding.
- archive: Introduce onProgress callback for resource archiving and utilize it in initSandboxArchive API to log real-time progress and errors.

* docs(skill): comprehensive user guides for agent skills

* refactor(sandbox): improve TS safety, simplify markdown frontmatter, and support sandboxToolMap info
parent 4b1f79f8
......@@ -67,7 +67,7 @@ jobs:
id: build
uses: docker/build-push-action@v6
with:
context: projects/agent-sandbox
context: .
file: projects/agent-sandbox/Dockerfile
platforms: linux/${{ matrix.arch }}
labels: |
......
name: Build fastgpt-ide-agent images
on:
workflow_dispatch:
jobs:
build-fastgpt-ide-agent-images:
permissions:
packages: write
contents: read
strategy:
matrix:
include:
- arch: amd64
- arch: arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on || 'ubuntu-24.04' }}
steps:
# install env
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Cache Docker layers
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-ide-agent-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-ide-agent-buildx-
# Push per-arch images by digest first; the release job publishes the latest manifest.
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build for ${{ matrix.arch }}
id: build
uses: docker/build-push-action@v6
with:
context: projects/fastgpt-ide-agent
file: projects/fastgpt-ide-agent/Dockerfile
platforms: linux/${{ matrix.arch }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.description=fastgpt-ide-agent image
outputs: type=image,"name=ghcr.io/${{ github.repository_owner }}/fastgpt-ide-agent",push-by-digest=true,push=true
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-fastgpt-ide-agent-${{ github.sha }}-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
release-fastgpt-ide-agent-images:
permissions:
packages: write
contents: read
needs: build-fastgpt-ide-agent-images
runs-on: ubuntu-24.04
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-fastgpt-ide-agent-${{ github.sha }}-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set image name and tag
run: |
echo "Git_Latest=ghcr.io/${{ github.repository_owner }}/fastgpt-ide-agent:latest" >> $GITHUB_ENV
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create -t "${Git_Latest}" \
$(printf 'ghcr.io/${{ github.repository_owner }}/fastgpt-ide-agent@sha256:%s ' *)
name: 'Rust-Agent-Test'
on:
pull_request:
paths:
- 'projects/agent-sandbox-proxy/**'
- 'projects/fastgpt-ide-agent/**'
- '.github/workflows/test-rust-agent.yaml'
push:
branches:
- main
paths:
- 'projects/agent-sandbox-proxy/**'
- 'projects/fastgpt-ide-agent/**'
- '.github/workflows/test-rust-agent.yaml'
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
test-rust-projects:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
shared-key: "fastgpt-rust-agent-cache"
workspaces: |
projects/agent-sandbox-proxy
projects/fastgpt-ide-agent
# --- agent-sandbox-proxy Checks & Tests ---
- name: Run agent-sandbox-proxy fmt check
run: cargo fmt --check
working-directory: projects/agent-sandbox-proxy
- name: Run agent-sandbox-proxy clippy check
run: cargo clippy --locked -- -D warnings
working-directory: projects/agent-sandbox-proxy
- name: Run agent-sandbox-proxy tests
run: cargo test --locked
working-directory: projects/agent-sandbox-proxy
# --- fastgpt-ide-agent Checks & Tests ---
- name: Run fastgpt-ide-agent fmt check
run: cargo fmt --check
working-directory: projects/fastgpt-ide-agent
- name: Run fastgpt-ide-agent clippy check
run: cargo clippy --locked -- -D warnings
working-directory: projects/fastgpt-ide-agent
- name: Run fastgpt-ide-agent tests
run: cargo test --locked
working-directory: projects/fastgpt-ide-agent
......@@ -42,6 +42,7 @@ pro/admin/worker/
.turbo
.antigravitycli
.chrome-user-data
# content_benchmark local secrets and generated artifacts
/pro/llm_benchmark/content_benchmark/.env.local
......
......@@ -209,7 +209,6 @@ ${{vec.db}}
AGENT_SANDBOX_ENABLE_VOLUME: true
AGENT_SANDBOX_VOLUME_MANAGER_URL: http://fastgpt-volume-manager:3000
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: *x-volume-manager-auth-token
AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH: /workspace
# ==================== 日志与监控 ====================
# 传递给 OTLP 收集器的服务名称
......
......@@ -5,6 +5,7 @@
"general",
"workflow",
"tools",
"skill",
"publish",
"evaluation",
"faq"
......
......@@ -5,6 +5,7 @@
"general",
"workflow",
"tools",
"skill",
"publish",
"evaluation",
"faq"
......
---
title: Development & Debugging
description: This guide details how to create or import a skill, and manage files, use the interactive terminal, and perform debug chat in Web IDE.
---
import { Alert } from '@/components/docs/Alert';
## 1. Creating & Importing Skills
Before writing code, you need to create a development project in the skills list. The platform supports two ways to create or load a skill:
![Creating & Importing Skills](/imgs/create_import_skill.png)
### 1.1 Click the Create Area to Create a Skill
Click the "Create" card (the dashed box area with a plus icon) on the page. In the popup, set the skill name, icon, description, and **requirements**. When the system initializes the skill in the background, it takes different approaches based on your input:
- **Using Default Template**: If you leave the default "Goal/Process/Requirements" template unchanged, the system will use the built-in basic structure and boilerplate code to generate the skill workspace (without invoking AI models, consuming no points).
- **AI-Assisted Generation**: If you input custom functional requirements here (e.g., "help me write a skill that extracts all email addresses from a text"), the system will invoke the **configured default system LLM model** in the background to automatically generate the `SKILL.md` scheme and initialize the code, which will consume points.
### 1.2 Import an Existing Skill ZIP Archive
If you have a skill backed up or shared by others, click the "Import Skill" button at the top right of the page and upload the corresponding ZIP archive. The system will automatically unzip it and restore all code files and configurations in the background, allowing you to resume development immediately.
---
## 2. Workspace File Management
When you open the skill details page, the system initializes and provisions an isolated run workspace in a secure sandbox container, loading your project via the file tree on the right.
![Workspace File Management](/imgs/workspace_file_management.png)
### 2.1 Multi-file Management
You can right-click or use action buttons on the file tree on the right to easily create, delete, rename, and move files or folders to organize your project structure.
### 2.2 Online Code Editing
Clicking any file in the tree opens it in the center editor:
- **Auto-Save**: The editor automatically saves and syncs your edits to the backend sandbox container as you type.
- **Real-Time Workspace Sync**: The file tree automatically monitors and syncs file changes. Whether you edit files, install package dependencies in the terminal, or background processes generate new files, the tree stays updated.
- **Change Isolation**: Any code changes made here only take effect instantly in the debugging environment, and will not directly affect live applications. To apply the latest code to production, you must click the "Publish" button to generate an official version. For details, please see [Versions & Publishing](/en/guide/build/skill/version).
### 2.3 Two-way Interactive Terminal
The command line terminal at the bottom right connects directly to the backend sandbox:
- **Running Commands**: You can enter various command line operations, such as installing required code dependencies online or executing various custom running and debugging scripts.
- **Log Feedback**: The terminal streams command execution logs in real time. If code or script execution errors occur, you can view the error messages directly in the terminal output to assist with debugging.
---
## 3. Agent Debug Chat
The agent debug panel on the left provides a testing environment, allowing you to test and call your custom skill logic in real time by chatting with the agent:
![Agent Debug Chat](/imgs/agent_debug_chat.png)
### 3.1 Immediate Effect
Every time you modify and save your code in the right editor, you don't need to manually compile, build, or redeploy. Simply send a new message in the chat box on the left, and the system will run the latest code in the background, allowing you to see the changes instantly.
### 3.2 Conversational Workspace Modification
You can directly chat with the agent to have it help you edit the file contents on the right (including creating, deleting, and modifying files). The file tree and editor on the right will reflect these changes in real time.
### 3.3 Real-time File Export
You can click "Export Config" in the top-right menu to package all code and configuration files in the current workspace into a ZIP archive and download it locally for backup or sharing.
---
title: 开发与调试
description: 详细介绍如何新建或导入技能,并在 Web IDE 中管理文件、使用交互终端以及进行对话调试。
---
import { Alert } from '@/components/docs/Alert';
## 1. 新建与导入技能
在开始编写代码前,你需要先在技能列表中创建一个开发项目。系统支持以下两种方式来创建或载入技能:
![新建与导入技能](/imgs/create_import_skill.png)
### 1.1 点击新建区域创建技能
在页面中点击带有加号的“新建”卡片,在弹出的窗口中设置技能名称、图标、介绍以及**需求描述**。系统在后台初始化该技能时,会根据你的输入采取不同的方式:
- **使用默认模板**:如果你保留默认的“目标/流程/要求”模板未作修改,系统将直接使用内置的基础结构和样例代码生成技能工作区(不调用 AI 模型,不产生积分消耗)。
- **AI 辅助生成**:如果你在此输入了具体的功能需求(例如“帮我编写一个从文本中提取所有邮箱地址的技能”),系统在后台会调用**系统配置的默认大语言模型**,根据你的描述自动生成技能方案 `SKILL.md` 并完成代码初始化,这会产生相应的积分消耗。
### 1.2 导入已有技能 ZIP 压缩包
如果你手头有自己备份或他人分享的技能,可以点击页面右上角的“导入技能”按钮,上传对应的 ZIP 格式技能压缩包,系统会自动解压并在后台还原所有代码文件与配置,让你能够立即在此基础上继续开发。
---
## 2. 工作区文件管理
当你在后台打开技能详情页时,系统会在安全的沙盒容器中为你初始化并拉起一个独立的运行空间,同时在右侧通过文件树加载你的项目。
![工作区文件管理](/imgs/workspace_file_management.png)
### 2.1 多文件管理
你可以在右侧的文件树上右键或点击按钮,轻松进行文件的新建、删除、重命名和移动,灵活组织项目的目录结构。
### 2.2 代码在线编辑
在文件树中点击任意文件即可在中央编辑器中打开并编辑代码:
- **自动保存**:编辑器自带自动保存机制,代码修改完成后,系统会自动同步并写入后台沙盒中。
- **实时状态刷新**:无论是你编辑保存、在终端安装依赖包,还是后台进程生成了新文件,右侧的文件树都会实时感知并刷新,自动同步展示最新状态。
- **变更隔离**:此处进行的所有代码修改仅在调试区即时生效,不会直接影响到线上已发布运行的应用。若需应用最新的代码,需要先点击“发布”生成正式版本,具体发布逻辑请详见 [版本与发布](/guide/build/skill/version)。
### 2.3 终端双向交互
右侧底部的命令行窗口(Terminal)直接连接到后台沙盒:
- **运行命令**:你可以在这里输入各种命令行操作,例如在线安装代码所需的依赖包,或是运行各类自定义测试与调试脚本等。
- **日志反馈**:终端会实时输出命令执行的过程和日志。如果代码或脚本运行出错,可以直接通过终端输出的报错信息来辅助定位问题。
---
## 3. 智能体对话调试
左侧的智能体调试面板提供了一个测试环境,允许你通过与智能体对话来实时测试和调用你编写的技能逻辑:
![智能体对话调试](/imgs/agent_debug_chat.png)
### 3.1 即改即生效
每次你在右侧编辑器中修改并保存代码后,无需手动进行编译、打包或重新部署。只需在左侧调试框中发送下一条消息,系统便会在后台自动运行你最新的代码,让你能够立即看到修改后的效果。
### 3.2 对话式工作区修改
你可以直接通过与智能体对话,让它帮你编辑右侧的文件内容(包括文件的创建、删除、修改等),右侧的文件树和编辑器会实时同步展示这些变化。
### 3.3 实时文件导出
你可以点击页面右上角菜单中的“导出配置”,将当前工作区中实时的所有代码与配置文件打包成 ZIP 压缩包下载到本地,方便进行本地备份或分享。
---
title: Agent Integration
description: How to bind published skills to AI agents and execute them.
---
import { Alert } from '@/components/docs/Alert';
## How to Bind a Skill to an Agent?
1. Go to the editing page of the **"Agent"** application where you want to integrate this skill (currently, only Agent applications support direct skill binding; simple apps and workflows do not support it).
2. In the configuration panel on the left, locate the **"Associated Skill"** section.
3. Click the **"Select"** button on the right, and in the popup list, select your published skill.
![Associate Skill & VM](/imgs/associated_skills_vm.png)
<Alert icon="💡" context="warning">
**Note:** Skill code needs to execute within a secure and isolated environment. Therefore, when
you associate a skill, the system will automatically enable the "Virtual Machine" for you; you
cannot disable the virtual machine while a skill remains associated.
</Alert>
---
## How Agents Call Skills
Once bound, the agent possesses this skill capability:
- **Multi-Skill Injection**: An agent can be bound to **multiple different skills** at the same time. When the sandbox (virtual machine) starts, all bound skill codes and configurations are automatically injected and deployed into the sandbox workspace. The skills are isolated from each other and will not conflict.
- **Automated Invocation**: **Provided that the LLM used is sufficiently intelligent**, you don't need to manually command the agent to run code. The AI will automatically judge whether to trigger the skill based on your input, and execute it securely in the background sandbox.
- **Virtual Machine File View**: You can click the **"Virtual Machine"** button at the bottom of the chat bubble (or the computer icon in the top right corner) to view all the latest files and code status in the virtual machine directly in the popup sidebar.
![Virtual Machine File View](/imgs/agent_chat_vm_files.png)
---
title: 智能体集成
description: 如何将发布好的技能绑定到 AI 智能体中并执行。
---
import { Alert } from '@/components/docs/Alert';
## 如何在应用中绑定技能?
1. 进入你想集成该技能的 **“智能体 (Agent)”** 应用编辑页面(目前仅智能体应用支持直接绑定技能,简易应用及工作流暂不支持)。
2. 在左侧的配置面板中,找到 **“关联 Skill”** 配置项。
3. 点击右侧的 **“选择”** 按钮,在弹出的选择窗口中,勾选你已经发布好的正式版本技能。
![关联 Skill 与虚拟机](/imgs/associated_skills_vm.png)
<Alert icon="💡" context="warning">
**注意:**
技能代码需要在安全隔离的环境中运行。因此,当你关联技能时,系统会自动为你开启“虚拟机”;并且在已关联技能的状态下,无法关闭虚拟机。
</Alert>
---
## 智能体如何调用技能?
绑定完成后,智能体即可获得该技能的执行能力:
- **多技能安全注入**:一个智能体支持同时绑定**多个不同的技能**。在沙盒(虚拟机)启动时,所有已绑定技能的代码和配置都会被自动注入并部署到沙盒工作区中,各技能间彼此独立、互不冲突。
- **智能自动调用**:**在使用的模型足够智能的前提下**,你无需手动命令智能体运行代码。AI 会根据你的输入,自动判断是否需要调用该技能,并在后台虚拟机中自动安全地执行代码。
- **虚拟机文件查看**:你可以点击聊天气泡底部的 **“虚拟机”** 按钮(或右上角的电脑图标),在弹出的侧边栏中直接查看虚拟机里当前最新的所有文件内容和代码状态。
![虚拟机文件查看](/imgs/agent_chat_vm_files.png)
---
title: Introduction
description: The concept of AI Agent Skills, and how it is designed and implemented in FastGPT.
---
import { Alert } from '@/components/docs/Alert';
## What is an AI Agent "Skill"?
Under the latest ecosystem designs of mainstream AI providers, a **"Skill"** is defined as a **persistent, reusable, and modular workflow and capability package**.
<Alert icon="🤖" context="success">
For example, if you frequently need the AI to audit complex spreadsheets and write analysis
reports, you can package the 'audit code' and 'report template' into a Skill. In future chats, you
can simply upload your spreadsheet, and the AI will run the skill in the background to compute
results and format the report.
</Alert>
---
## Core Design Philosophy: From Tools to Skills
In the general cognitive framework of AI Agents, we typically divide capabilities into three layers:
- **The Brain (Brain)**: Responsible for reasoning and planning (the LLM itself).
- **Tools (Tools)**: Simple execution interfaces (such as sending a web request or running a temporary line of code), resembling the AI's "hands and feet".
- **Skills (Skills)**: Providing the complete **"operational knowledge and professional logic"** (Know-how).
A skill is typically a modular package encapsulating **instruction markdown (how to do it)** and **executable scripts (actually doing it)**.
If a tool is a "screwdriver" in your toolbox, then a skill is a **"furniture assembly guide"**. The AI can automatically grab this guide from its skill library based on the current context, execute the code inside a background sandbox, and complete the complex assembly.
---
## Skills in FastGPT
Following the industry-standard design of Skills, FastGPT provides you with a "**dedicated code execution workspace**" featuring the following core designs:
![Skill List](/imgs/skill_list_intro.png)
### 1. Isolated Secure Runtime Sandbox
Each created skill during editing runs in a fully isolated, secure sandbox environment (powered by Sealos Devbox, OpenSandbox, etc., in the backend). All operations are restricted within this workspace to ensure safety.
### 2. Instant Hot-Reloading Debugging
Provides an online debugging environment integrating a file tree, code editor, and console terminal. Equipped with an agent debug panel on the left supporting hot reloading, allowing you to troubleshoot the skill before publishing.
### 3. Isolation of Production & Debugging
Edits in the workspace will only take effect instantly in the "Debug Chat" area. Changes will only be applied to production agents once you click publish and snap a new version, ensuring service stability.
### 4. Auto-Sleep & Seamless Invocation
For long-inactive skills, the system automatically shuts down the sandbox and performs cold-archiving to storage. When edit or agent invocation resumes, the sandbox is automatically re-instantiated and restored from the archive in the background. You are only billed when the skill is active, dramatically reducing your runtime costs.
---
title: 基础介绍
description: AI 智能体技能概念,以及它在 FastGPT 中的设计与实现原理。
---
import { Alert } from '@/components/docs/Alert';
## 什么是 AI 智能体的“技能”?
在当前主流 AI 厂商的最新生态设计中,**“技能”(Skills)** 被定义为一种**可持久保存、可复用的模块化专业流程与能力包**。
<Alert icon="🤖" context="success">
例如,如果你经常需要 AI
帮你核对两份复杂的财务表格并生成分析,你只需一次性把“计算代码”和“报告模板”放入技能中。在以后的对话中,你直接把表格丢给
AI,它就能自动在后台调用这个技能把数据算准、格式排好。
</Alert>
---
## 核心设计原理:从工具到技能
在 AI 智能体(Agent)的大众认知中,我们通常将它划分为三个层面:
- **大脑 (Brain)**:负责规划和推理,是大模型本身。
- **工具 (Tools)**:提供单纯的“动作接口”(例如:发送一段网络请求、运行一行临时代码),类似于 AI 的“手和脚”。
- **技能 (Skills)**:提供完整的“**做事章法与专业逻辑**”(Know-how)。
一个技能通常是由**说明文档(指明怎么做)**和**逻辑代码(真正去执行)**封装在一起的模块化包。如果工具是工具箱里的“螺丝刀”,那么技能就是一张**“家具组装手册”**,AI 能够自动根据当前对话任务,伸手从它的技能库里拿取这本手册,在后台沙盒中运行代码并完成复杂的装配任务。
---
## FastGPT 中的技能设计
承袭业界主流的技能(Skills)设计标准,FastGPT 支持你为智能体创建“**专属的独立代码空间**”,具备以下核心设计:
![技能列表](/imgs/skill_list_intro.png)
### 1. 独立的安全运行沙箱
创建出来的每个技能在编辑时都拥有一个完全隔离的安全运行沙箱(后台基于 Sealos Devbox、OpenSandbox 等沙盒服务运行)。所有操作都在此隔离空间内进行,保障技能执行的安全性。
### 2. 即改即生效的调试环境
提供了一个集成了文件管理、代码编辑器和交互式终端的在线调试环境。左侧配有智能体调试面板,支持“即改即生效”的热重载,方便你在发布前对技能进行充分的调试与排错。
### 3. 生产与调试环境隔离
在编辑区域直接修改的代码只在“调试区”即时生效。只有点击“发布”生成并保存正式版本后,改动才会正式应用到生产环境的智能体与工作流中。
### 4. 自动休眠与无感唤醒
针对长期闲置的技能,系统会自动将其从沙盒中清理并冷归档至存储。当需要再次编辑或被智能体调用时,会自动在后台重新拉起沙箱并复原。休眠期间不产生任何运行计费,大幅降低使用成本。
{
"title": "Skills",
"root": false,
"pages": [
"intro",
"development",
"version",
"integration"
]
}
{
"title": "技能",
"root": false,
"pages": [
"intro",
"development",
"version",
"integration"
]
}
---
title: Versions & Publishing
description: Why you need to publish versions, how to save snapshots, and easy rollback to historical versions.
---
import { Alert } from '@/components/docs/Alert';
## Why do you need to "Publish"?
Edits in the editor only take effect in the "Debug Chat" panel. To formally apply your changes to your workflows or agents, you must click "Publish" to deploy a formal version.
<Alert icon="💡" context="warning">
**Note:** The debugging environment is isolated from the production deployment environment. This
ensures that when you edit or debug skill code, it will not affect the online agents and workflows
currently running.
</Alert>
---
## Saving Version Snapshots
1. Once testing is successful, click the **"Publish"** button in the top right corner of the editor.
2. In the modal, enter the **Version Name** (by default, the current time is prefilled, but you can customize it, e.g., `v1.0.0`).
3. Confirm, and the system will solidify the current code state as an "official version" and publish it.
<Alert icon="💡" context="warning">
**Note:** During publishing, the system automatically applies the ignore rules specified in the
`.gitignore` file at the project root (if not present, a default file ignoring `node_modules`,
`.venv`, `dist`, etc., will be created). Only files that are not ignored will be packaged, and you
must ensure the total size of these files does not exceed the limit, otherwise publishing may
fail.
</Alert>
---
## Version Rollback
To restore a previous version:
1. Click the **"Version History"** (clock) icon in the top right corner of the editor to view all published snapshots.
2. Hover over the version you want to restore, and click the **"Switch"** (return arrow) icon to instantly revert both your workspace files and the live production version back to that snapshot.
<Alert icon="💡" context="warning">
**Note:** Restored versions will not carry files that were ignored by `.gitignore` (for example,
local files like `node_modules` or `.venv` that were ignored cannot be recovered via rollback).
</Alert>
![Version History & Rollback](/imgs/version_history_rollback.png)
---
title: 版本与发布
description: 为什么需要发布版本,如何保存快照,以及历史版本的轻松回滚。
---
import { Alert } from '@/components/docs/Alert';
## 为什么要“发布”?
在编辑器里直接修改的代码只在“调试区”即时生效。如果你想在工作流或者智能体当中正式应用你的改动,必须点击“发布”生成一个正式部署版本。
<Alert icon="💡" context="warning">
**注意:**
调试环境与生产部署环境是隔离的。这样可以确保你在调试、改写技能代码时,不会影响线上正在运行的智能体与工作流服务。
</Alert>
---
## 保存版本快照
1. 调试确认无误后,点击编辑器右上角的 **“发布”** 按钮。
2. 在弹出的窗口中,输入当前版本的**版本名称**(默认会自动填充当前时间作为名称,你也可以自定义修改,例如输入 `v1.0.0`)。
3. 确认后,系统会将当前的代码状态固化为一个“正式版本”发布上线。
<Alert icon="💡" context="warning">
**注意:** 发布时,系统会自动应用项目根目录下 `.gitignore`
文件的忽略规则(如不存在,系统会自动创建包含 `node_modules`、`.venv`、`dist`
等默认忽略项的文件)。只有未被忽略的文件才会被打包发布,请确保打包文件总体积未超限,否则可能导致发布失败。
</Alert>
---
## 历史版本回滚
如果需要恢复到以前的版本:
1. 点击编辑器右上角的 **“版本历史”**(时钟)图标,查看已发布的所有历史快照。
2. 将鼠标悬停在要恢复的历史版本上,点击 **“切换”**(返回箭头)图标,即可一键将工作区文件以及当前线上运行的版本同时切换回该历史版本。
<Alert icon="💡" context="warning">
**注意:** 回滚的版本不会携带被 `.gitignore` 忽略的文件(如依赖包 `node_modules`、虚拟环境 `.venv`
等已忽略的本地文件不会被恢复)。
</Alert>
![版本历史与回退](/imgs/version_history_rollback.png)
......@@ -51,8 +51,8 @@ These variables are mainly validated by `packages/service/env.ts` and apply to `
### Agent Sandbox
| Variable | Default | Description |
| -------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| `AGENT_SANDBOX_PROVIDER` | Empty | Agent sandbox provider. Supported values are `sealosdevbox`, `opensandbox`, and `e2b`. Empty means Agent Sandbox is disabled. |
| -------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AGENT_SANDBOX_PROVIDER` | Empty | Agent sandbox provider. Supported values are `sealosdevbox`, `opensandbox`, and `e2b`. The feature is enabled only when this value and the matching provider credentials and proxy config are set. |
| `AGENT_SANDBOX_E2B_API_KEY` | Empty | E2B sandbox API key. |
| `AGENT_SANDBOX_SEALOS_BASEURL` | Empty | Sealos Devbox service URL. |
| `AGENT_SANDBOX_SEALOS_TOKEN` | Empty | Sealos Devbox access token. |
......@@ -60,21 +60,18 @@ These variables are mainly validated by `packages/service/env.ts` and apply to `
| `AGENT_SANDBOX_OPENSANDBOX_BASEURL` | Empty | OpenSandbox service URL. |
| `AGENT_SANDBOX_OPENSANDBOX_API_KEY` | Empty | OpenSandbox API key. Required when OpenSandbox is enabled, and must match OpenSandbox server `[server].api_key`. |
| `AGENT_SANDBOX_OPENSANDBOX_RUNTIME` | `docker` | OpenSandbox runtime, either `docker` or `kubernetes`. |
| `AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO` | Empty | Image repository used by OpenSandbox. |
| `AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO` | `fastgpt-agent-sandbox` | Image repository used by OpenSandbox. |
| `AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG` | `latest` | Image tag used by OpenSandbox. |
| `AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY` | `true` | Whether OpenSandbox access goes through the server proxy. |
| `AGENT_SANDBOX_ENABLE_VOLUME` | `false` | Whether persistent volumes are enabled for Agent Sandbox. |
| `AGENT_SANDBOX_VOLUME_MANAGER_URL` | Empty | Volume Manager service URL. |
| `AGENT_SANDBOX_VOLUME_MANAGER_TOKEN` | Empty | Volume Manager authentication token. |
| `AGENT_SANDBOX_PROXY_SECRET` | Empty | Shared HMAC secret for the app and agent-sandbox-proxy. Required when Agent Sandbox is enabled; must be at least 32 bytes. |
| `AGENT_SANDBOX_PROXY_URL` | Empty | Browser-accessible WebSocket URL for agent-sandbox-proxy. Must start with `ws://` or `wss://`. |
| `AGENT_SANDBOX_FREE_TIP` | `false` | Whether the frontend shows the Agent Sandbox free-use hint. |
| `AGENT_SANDBOX_MAX_EDIT_DEBUG` | Empty | Limit for Agent edit/debug sandboxes. Empty means unlimited. |
| `AGENT_SANDBOX_MAX_SESSION_RUNTIME` | Empty | Limit for Agent sandbox session runtime. Empty means unlimited. |
### Skill Limits
| Variable | Default | Description |
| ----------------------------- | ------- | ------------------------------------------------------------------------------------------------------- |
| `AGENT_SKILL_MAX_UPLOAD_SIZE` | `50` | Maximum Skill package size, in MB. Used for upload, extraction, download, and sandbox packaging checks. |
| `AGENT_SANDBOX_ARCHIVE_MAX_SIZE` | `10` | Maximum Agent sandbox cold archive and Skill package size, in MB. Used for upload, download, and packaging checks for those packages. |
| `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. |
### Databases, Cache, and Vector Stores
......
......@@ -51,8 +51,8 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说
### Agent Sandbox
| 变量 | 默认值 | 说明 |
| -------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------- |
| `AGENT_SANDBOX_PROVIDER` | 空 | Agent 沙箱提供方,可选 `sealosdevbox`、`opensandbox`、`e2b`;为空时不启用 Agent 沙箱。 |
| -------------------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `AGENT_SANDBOX_PROVIDER` | 空 | Agent 沙箱提供方,可选 `sealosdevbox`、`opensandbox`、`e2b`;仅在配置该值及对应 provider 认证、代理配置后启用。 |
| `AGENT_SANDBOX_E2B_API_KEY` | 空 | E2B 沙箱 API Key。 |
| `AGENT_SANDBOX_SEALOS_BASEURL` | 空 | Sealos Devbox 服务地址。 |
| `AGENT_SANDBOX_SEALOS_TOKEN` | 空 | Sealos Devbox 访问 Token。 |
......@@ -60,21 +60,18 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说
| `AGENT_SANDBOX_OPENSANDBOX_BASEURL` | 空 | OpenSandbox 服务地址。 |
| `AGENT_SANDBOX_OPENSANDBOX_API_KEY` | 空 | OpenSandbox API Key;启用 OpenSandbox 时必填,并且必须与 OpenSandbox server 的 `[server].api_key` 一致。 |
| `AGENT_SANDBOX_OPENSANDBOX_RUNTIME` | `docker` | OpenSandbox 运行时,可选 `docker` 或 `kubernetes`。 |
| `AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO` | 空 | OpenSandbox 使用的镜像仓库。 |
| `AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO` | `fastgpt-agent-sandbox` | OpenSandbox 使用的镜像仓库。 |
| `AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG` | `latest` | OpenSandbox 使用的镜像标签。 |
| `AGENT_SANDBOX_OPENSANDBOX_USE_SERVER_PROXY` | `true` | OpenSandbox 是否通过服务端代理访问。 |
| `AGENT_SANDBOX_ENABLE_VOLUME` | `false` | Agent 沙箱是否启用持久化 Volume。 |
| `AGENT_SANDBOX_VOLUME_MANAGER_URL` | 空 | Volume Manager 服务地址。 |
| `AGENT_SANDBOX_VOLUME_MANAGER_TOKEN` | 空 | Volume Manager 认证 Token。 |
| `AGENT_SANDBOX_PROXY_SECRET` | 空 | agent-sandbox-proxy 与主站共用的 HMAC 密钥;启用 Agent Sandbox 时必填,至少 32 字节。 |
| `AGENT_SANDBOX_PROXY_URL` | 空 | 浏览器访问 agent-sandbox-proxy 的 WebSocket 地址,必须以 `ws://` 或 `wss://` 开头。 |
| `AGENT_SANDBOX_FREE_TIP` | `false` | 前端是否展示 Agent Sandbox 免费提示。 |
| `AGENT_SANDBOX_MAX_EDIT_DEBUG` | 空 | Agent 编辑/调试沙箱数量限制;为空表示不限制。 |
| `AGENT_SANDBOX_MAX_SESSION_RUNTIME` | 空 | Agent 沙箱会话运行时长限制;为空表示不限制。 |
### Skill 限制
| 变量 | 默认值 | 说明 |
| ----------------------------- | ------ | -------------------------------------------------------------------- |
| `AGENT_SKILL_MAX_UPLOAD_SIZE` | `50` | Skill 包大小上限,单位 MB;用于上传、解压、下载和 sandbox 打包校验。 |
| `AGENT_SANDBOX_ARCHIVE_MAX_SIZE` | `10` | Agent 沙箱冷归档包及 Skill 包大小上限,单位 MB;用于这两类包的上传、下载和打包校验。 |
| `AGENT_SANDBOX_MAX_FILE_SIZE` | `10` | Agent 沙箱 IDE 单文件读写和上传大小上限,单位 MB。 |
| `AGENT_SANDBOX_MAX_EDIT_DEBUG` | `100` | Agent 编辑/调试沙箱数量限制。 |
### 数据库、缓存与向量库
......
......@@ -18,6 +18,10 @@ description: FastGPT Toc
- [/en/guide/build/publish/openapi](/en/guide/build/publish/openapi)
- [/en/guide/build/publish/wechat](/en/guide/build/publish/wechat)
- [/en/guide/build/publish/wecom](/en/guide/build/publish/wecom)
- [/en/guide/build/skill/development](/en/guide/build/skill/development)
- [/en/guide/build/skill/integration](/en/guide/build/skill/integration)
- [/en/guide/build/skill/intro](/en/guide/build/skill/intro)
- [/en/guide/build/skill/version](/en/guide/build/skill/version)
- [/en/guide/build/tools/mcp_tools](/en/guide/build/tools/mcp_tools)
- [/en/guide/build/tools/system-plugins/upload_system_tool](/en/guide/build/tools/system-plugins/upload_system_tool)
- [/en/guide/build/workflow/intro](/en/guide/build/workflow/intro)
......
......@@ -18,6 +18,10 @@ description: FastGPT 文档目录
- [/guide/build/publish/openapi](/guide/build/publish/openapi)
- [/guide/build/publish/wechat](/guide/build/publish/wechat)
- [/guide/build/publish/wecom](/guide/build/publish/wecom)
- [/guide/build/skill/development](/guide/build/skill/development)
- [/guide/build/skill/integration](/guide/build/skill/integration)
- [/guide/build/skill/intro](/guide/build/skill/intro)
- [/guide/build/skill/version](/guide/build/skill/version)
- [/guide/build/tools/mcp_tools](/guide/build/tools/mcp_tools)
- [/guide/build/tools/system-plugins/upload_system_tool](/guide/build/tools/system-plugins/upload_system_tool)
- [/guide/build/workflow/intro](/guide/build/workflow/intro)
......
......@@ -31,6 +31,14 @@
"content/guide/build/publish/wechat.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/build/publish/wecom.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/build/publish/wecom.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/build/skill/development.en.mdx": "2026-06-16T00:57:42+08:00",
"content/guide/build/skill/development.mdx": "2026-06-16T00:57:42+08:00",
"content/guide/build/skill/integration.en.mdx": "2026-06-16T00:57:42+08:00",
"content/guide/build/skill/integration.mdx": "2026-06-16T00:57:42+08:00",
"content/guide/build/skill/intro.en.mdx": "2026-06-16T00:57:42+08:00",
"content/guide/build/skill/intro.mdx": "2026-06-16T00:57:42+08:00",
"content/guide/build/skill/version.en.mdx": "2026-06-16T00:57:42+08:00",
"content/guide/build/skill/version.mdx": "2026-06-16T00:57:42+08:00",
"content/guide/build/tools/mcp_tools.en.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/build/tools/mcp_tools.mdx": "2026-05-07T15:06:40+08:00",
"content/guide/build/tools/system-plugins/upload_system_tool.en.mdx": "2026-05-07T15:06:40+08:00",
......@@ -149,8 +157,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-14T00:12:11+08:00",
"content/self-host/config/env.mdx": "2026-06-14T00:12:11+08:00",
"content/self-host/config/env.en.mdx": "2026-06-08T18:29:27+08:00",
"content/self-host/config/env.mdx": "2026-06-08T18:29:27+08:00",
"content/self-host/config/json.en.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/config/json.mdx": "2026-05-25T11:21:30+08:00",
"content/self-host/config/model/intro.en.mdx": "2026-06-04T16:10:15+08:00",
......@@ -275,9 +283,9 @@
"content/self-host/upgrading/4-15/41503.en.mdx": "2026-05-28T16:21:09+08:00",
"content/self-host/upgrading/4-15/41503.mdx": "2026-05-28T16:21:09+08:00",
"content/self-host/upgrading/4-15/41504.en.mdx": "2026-06-10T19:02:59+08:00",
"content/self-host/upgrading/4-15/41504.mdx": "2026-06-14T22:25:36+08:00",
"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-12T20:47:04+08:00",
"content/self-host/upgrading/4-15/41505.mdx": "2026-06-15T21:58:39+08:00",
"content/self-host/upgrading/4-15/41505.mdx": "2026-06-15T23:34:43+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",
......@@ -418,6 +426,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-12T00:30:58+08:00",
"content/toc.mdx": "2026-06-12T00:30:58+08:00"
"content/toc.en.mdx": "2026-06-16T00:57:42+08:00",
"content/toc.mdx": "2026-06-16T00:57:42+08:00"
}
\ No newline at end of file
......@@ -266,7 +266,6 @@ services:
AGENT_SANDBOX_ENABLE_VOLUME: true
AGENT_SANDBOX_VOLUME_MANAGER_URL: http://fastgpt-volume-manager:3000
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: *x-volume-manager-auth-token
AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH: /workspace
# ==================== 日志与监控 ====================
# 传递给 OTLP 收集器的服务名称
......
......@@ -244,7 +244,6 @@ services:
AGENT_SANDBOX_ENABLE_VOLUME: true
AGENT_SANDBOX_VOLUME_MANAGER_URL: http://fastgpt-volume-manager:3000
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: *x-volume-manager-auth-token
AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH: /workspace
# ==================== 日志与监控 ====================
# 传递给 OTLP 收集器的服务名称
......
......@@ -228,7 +228,6 @@ services:
AGENT_SANDBOX_ENABLE_VOLUME: true
AGENT_SANDBOX_VOLUME_MANAGER_URL: http://fastgpt-volume-manager:3000
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: *x-volume-manager-auth-token
AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH: /workspace
# ==================== 日志与监控 ====================
# 传递给 OTLP 收集器的服务名称
......
......@@ -226,7 +226,6 @@ services:
AGENT_SANDBOX_ENABLE_VOLUME: true
AGENT_SANDBOX_VOLUME_MANAGER_URL: http://fastgpt-volume-manager:3000
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: *x-volume-manager-auth-token
AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH: /workspace
# ==================== 日志与监控 ====================
# 传递给 OTLP 收集器的服务名称
......
......@@ -231,7 +231,6 @@ services:
AGENT_SANDBOX_ENABLE_VOLUME: true
AGENT_SANDBOX_VOLUME_MANAGER_URL: http://fastgpt-volume-manager:3000
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: *x-volume-manager-auth-token
AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH: /workspace
# ==================== 日志与监控 ====================
# 传递给 OTLP 收集器的服务名称
......
......@@ -210,7 +210,6 @@ services:
AGENT_SANDBOX_ENABLE_VOLUME: true
AGENT_SANDBOX_VOLUME_MANAGER_URL: http://fastgpt-volume-manager:3000
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: *x-volume-manager-auth-token
AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH: /workspace
# ==================== 日志与监控 ====================
# 传递给 OTLP 收集器的服务名称
......
......@@ -266,7 +266,6 @@ services:
AGENT_SANDBOX_ENABLE_VOLUME: true
AGENT_SANDBOX_VOLUME_MANAGER_URL: http://fastgpt-volume-manager:3000
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: *x-volume-manager-auth-token
AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH: /workspace
# ==================== 日志与监控 ====================
# 传递给 OTLP 收集器的服务名称
......
......@@ -244,7 +244,6 @@ services:
AGENT_SANDBOX_ENABLE_VOLUME: true
AGENT_SANDBOX_VOLUME_MANAGER_URL: http://fastgpt-volume-manager:3000
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: *x-volume-manager-auth-token
AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH: /workspace
# ==================== 日志与监控 ====================
# 传递给 OTLP 收集器的服务名称
......
......@@ -228,7 +228,6 @@ services:
AGENT_SANDBOX_ENABLE_VOLUME: true
AGENT_SANDBOX_VOLUME_MANAGER_URL: http://fastgpt-volume-manager:3000
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: *x-volume-manager-auth-token
AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH: /workspace
# ==================== 日志与监控 ====================
# 传递给 OTLP 收集器的服务名称
......
......@@ -226,7 +226,6 @@ services:
AGENT_SANDBOX_ENABLE_VOLUME: true
AGENT_SANDBOX_VOLUME_MANAGER_URL: http://fastgpt-volume-manager:3000
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: *x-volume-manager-auth-token
AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH: /workspace
# ==================== 日志与监控 ====================
# 传递给 OTLP 收集器的服务名称
......
......@@ -231,7 +231,6 @@ services:
AGENT_SANDBOX_ENABLE_VOLUME: true
AGENT_SANDBOX_VOLUME_MANAGER_URL: http://fastgpt-volume-manager:3000
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: *x-volume-manager-auth-token
AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH: /workspace
# ==================== 日志与监控 ====================
# 传递给 OTLP 收集器的服务名称
......
......@@ -210,7 +210,6 @@ services:
AGENT_SANDBOX_ENABLE_VOLUME: true
AGENT_SANDBOX_VOLUME_MANAGER_URL: http://fastgpt-volume-manager:3000
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: *x-volume-manager-auth-token
AGENT_SANDBOX_VOLUME_MANAGER_MOUNT_PATH: /workspace
# ==================== 日志与监控 ====================
# 传递给 OTLP 收集器的服务名称
......
......@@ -17,6 +17,7 @@ export enum TrackEnum {
freeAccountCleanup = 'freeAccountCleanup',
auditLogCleanup = 'auditLogCleanup',
chatHistoryCleanup = 'chatHistoryCleanup',
sandboxArchive = 'sandboxArchive',
// web tracks
clientError = 'clientError',
......
......@@ -111,7 +111,9 @@ export type FastGPTFeConfigsType = {
websiteSyncLimitMinuted?: number;
agentSandboxMaxEditDebug?: number;
agentSandboxMaxSessionRuntime?: number;
agentSkillMaxUploadBytes?: number;
agentSandboxArchiveMaxBytes?: number;
skillSandboxMaxBytes?: number;
agentSandboxMaxFileBytes?: number;
workflowParallelRunMaxConcurrency?: number;
maxFolderDepth?: number;
};
......@@ -148,6 +150,7 @@ export type FastGPTFeConfigsType = {
// tmp
agentSandboxFree?: boolean;
agentSandboxProxyUrl?: string;
};
export type SystemEnvType = {
......
export type AgentSandboxEnvSource = Record<string, string | undefined>;
/**
* 判断系统是否显式配置了 Agent 虚拟机能力。
* 必须基于原始 env 判断,避免被服务端 env schema 的默认 provider 误判为已启用。
*/
export const hasAgentSandboxConfig = (env: AgentSandboxEnvSource): boolean => {
const provider = env.AGENT_SANDBOX_PROVIDER;
if (provider === 'sealosdevbox') {
return !!(env.AGENT_SANDBOX_SEALOS_BASEURL && env.AGENT_SANDBOX_SEALOS_TOKEN);
}
if (provider === 'opensandbox') {
return !!(env.AGENT_SANDBOX_OPENSANDBOX_BASEURL && env.AGENT_SANDBOX_OPENSANDBOX_API_KEY);
}
if (provider === 'e2b') {
return !!env.AGENT_SANDBOX_E2B_API_KEY;
}
return false;
};
import type { I18nStringType, localeType } from '../../../../common/i18n/type';
import { AGENT_SANDBOX_TOOLSET_ID, SANDBOX_ICON, SANDBOX_NAME } from '../../../ai/sandbox/tools';
import {
AGENT_SANDBOX_TOOLSET_ID,
SANDBOX_ICON,
SANDBOX_NAME,
sandboxToolMap
} from '../../../ai/sandbox/tools';
import { parseI18nString } from '../../../../common/i18n/utils';
export enum SubAppIds {
......@@ -40,6 +45,15 @@ export const systemSubInfo: Record<
}
};
export const getSystemToolInfo = (id: string, lang: localeType = 'en') => {
if (id in sandboxToolMap) {
const info = sandboxToolMap[id];
return {
name: parseI18nString(info.name, lang),
avatar: info.avatar,
toolDescription: info.toolDescription
};
}
if (id in systemSubInfo) {
const info = systemSubInfo[id];
return {
......
......@@ -14,128 +14,6 @@ const SandboxBaseSchema = z.object({
});
/**
* 列出目录 - 请求/响应
*/
export const SandboxListBodySchema = SandboxBaseSchema.extend({
path: z.string().default('.').describe('目录路径')
});
export type SandboxListBody = z.infer<typeof SandboxListBodySchema>;
export const SandboxFileItemSchema = z.object({
name: z.string().describe('文件名'),
path: z.string().describe('完整路径'),
type: z.enum(['file', 'directory']).describe('文件类型'),
size: z.number().optional().describe('文件大小(字节数)')
});
export type SandboxFileItem = z.infer<typeof SandboxFileItemSchema>;
export const SandboxListResponseSchema = z.object({
files: z.array(SandboxFileItemSchema)
});
export type SandboxListResponse = z.infer<typeof SandboxListResponseSchema>;
/* ============================================================================
* API: 递归列出沙盒目录
* Route: POST /api/core/ai/sandbox/listRecursive
* Method: POST
* Description: 一次性获取指定目录下的文件树,用于 Skill Edit 初始化文件列表
* Tags: ['Sandbox', 'Read']
* ============================================================================ */
export const SandboxListRecursiveBodySchema = SandboxListBodySchema.extend({
excludeNames: z
.array(z.string())
.optional()
.meta({
example: ['node_modules', '.git', 'dist'],
description: '需要跳过的文件或目录名称,仅按文件名匹配'
}),
maxDepth: z.number().int().min(0).max(20).default(20).meta({
example: 20,
description: '最大递归深度,0 表示只返回当前目录的直接子项'
})
});
export type SandboxListRecursiveBody = z.input<typeof SandboxListRecursiveBodySchema>;
export type SandboxFileTreeItem = SandboxFileItem & {
children?: SandboxFileTreeItem[];
level: number;
loaded?: boolean;
};
export const SandboxFileTreeItemSchema: z.ZodType<SandboxFileTreeItem> =
SandboxFileItemSchema.extend({
children: z
.lazy(() => z.array(SandboxFileTreeItemSchema))
.optional()
.meta({
description: '子节点。文件没有该字段,目录在未加载到更深层时可能为空数组'
}),
level: z.number().int().nonnegative().meta({
example: 0,
description: '节点层级,相对于请求 path 的直接子项为 0'
}),
loaded: z.boolean().optional().meta({
example: true,
description: '目录子节点是否已完整加载;达到 maxDepth 截断时为 false'
})
});
export const SandboxListRecursiveResponseSchema = z.object({
files: z.array(SandboxFileTreeItemSchema).meta({
description: '递归目录树',
example: [
{
name: 'src',
path: 'src',
type: 'directory',
level: 1,
loaded: true,
children: [
{
name: 'index.ts',
path: 'src/index.ts',
type: 'file',
size: 128,
level: 2
}
]
}
]
}),
expandedPaths: z.array(z.string()).meta({
example: ['src'],
description: '默认展开的目录路径'
})
});
export type SandboxListRecursiveResponse = z.infer<typeof SandboxListRecursiveResponseSchema>;
/**
* 写入文件 - 请求/响应
*/
export const SandboxWriteBodySchema = SandboxBaseSchema.extend({
path: z.string().describe('文件路径'),
content: z.string().describe('文件内容')
});
export type SandboxWriteBody = z.infer<typeof SandboxWriteBodySchema>;
export const SandboxWriteResponseSchema = z.object({
success: z.boolean()
});
export type SandboxWriteResponse = z.infer<typeof SandboxWriteResponseSchema>;
/**
* 读取文件内容 - 请求体(响应为原始文件流)
*/
export const SandboxReadBodySchema = SandboxBaseSchema.extend({
path: z.string().describe('文件路径')
});
export type SandboxReadBody = z.infer<typeof SandboxReadBodySchema>;
export const SandboxReadResponseSchema = z
.string()
.meta({ format: 'binary', description: '文件内容流' });
/**
* 下载文件或目录 - 请求体(响应为文件流或 ZIP)
*/
export const SandboxDownloadBodySchema = SandboxBaseSchema.extend({
......@@ -158,6 +36,26 @@ export type SandboxCheckExistBody = z.infer<typeof SandboxCheckExistBodySchema>;
export type SandboxCheckExistResponse = z.infer<typeof SandboxCheckExistResponseSchema>;
/**
* 获取沙盒 WebSocket 临时访问凭证。
*/
export const SandboxChannelSchema = z.enum(['fs', 'terminal']).describe('沙盒 WebSocket 通道');
export const SandboxTicketPermissionSchema = z.enum(['read', 'write']).describe('沙盒 Ticket 权限');
export const SandboxGetTicketBodySchema = SandboxBaseSchema.extend({
channel: SandboxChannelSchema,
permission: SandboxTicketPermissionSchema.optional()
.default('read')
.describe('fs 通道支持 read/write;terminal 通道固定需要 write')
});
export const SandboxGetTicketResponseSchema = z.object({
ticket: z.string().describe('沙盒 WebSocket 临时访问凭证')
});
export type SandboxChannel = z.infer<typeof SandboxChannelSchema>;
export type SandboxTicketPermission = z.infer<typeof SandboxTicketPermissionSchema>;
export type SandboxGetTicketBody = z.input<typeof SandboxGetTicketBodySchema>;
export type SandboxGetTicketResponse = z.infer<typeof SandboxGetTicketResponseSchema>;
/**
* 获取 HTML 预览链接 - 请求/响应
*/
export const SandboxGetHtmlPreviewLinkBodySchema = SandboxBaseSchema.extend({
......@@ -168,19 +66,3 @@ export type SandboxGetHtmlPreviewLinkBody = z.infer<typeof SandboxGetHtmlPreview
export type SandboxGetHtmlPreviewLinkResponse = z.infer<
typeof SandboxGetHtmlPreviewLinkResponseSchema
>;
/**
* 文件系统操作 - 请求/响应
*/
export const SandboxFileOpBodySchema = SandboxBaseSchema.extend({
type: z.enum(['mkdir', 'delete', 'move', 'copy']).describe('操作类型'),
path: z.string().describe('当前路径'),
destPath: z.string().optional().describe('目标路径')
});
export type SandboxFileOpBody = z.infer<typeof SandboxFileOpBodySchema>;
export const SandboxFileOpResponseSchema = z.object({
success: z.boolean(),
message: z.string().optional()
});
export type SandboxFileOpResponse = z.infer<typeof SandboxFileOpResponseSchema>;
import type { OpenAPIPath } from '../../../type';
import { TagsMap } from '../../../tag';
import {
SandboxListBodySchema,
SandboxListResponseSchema,
SandboxListRecursiveBodySchema,
SandboxListRecursiveResponseSchema,
SandboxWriteBodySchema,
SandboxWriteResponseSchema,
SandboxReadBodySchema,
SandboxReadResponseSchema,
SandboxDownloadBodySchema,
SandboxDownloadResponseSchema,
SandboxCheckExistBodySchema,
SandboxCheckExistResponseSchema,
SandboxGetTicketBodySchema,
SandboxGetTicketResponseSchema,
SandboxGetHtmlPreviewLinkBodySchema,
SandboxGetHtmlPreviewLinkResponseSchema,
SandboxFileOpBodySchema,
SandboxFileOpResponseSchema
SandboxGetHtmlPreviewLinkResponseSchema
} from './api';
export const SandboxPath: OpenAPIPath = {
'/core/ai/sandbox/list': {
post: {
summary: '列出沙盒目录',
description: '列出指定目录下的文件和子目录',
tags: [TagsMap.sandbox],
requestBody: {
content: {
'application/json': {
schema: SandboxListBodySchema
}
}
},
responses: {
200: {
description: '目录内容',
content: {
'application/json': {
schema: SandboxListResponseSchema
}
}
}
}
}
},
'/core/ai/sandbox/listRecursive': {
post: {
summary: '递归列出沙盒目录',
description: '递归列出指定目录下的文件和子目录,并返回可直接渲染的目录树',
tags: [TagsMap.sandbox],
requestBody: {
content: {
'application/json': {
schema: SandboxListRecursiveBodySchema
}
}
},
responses: {
200: {
description: '递归目录树',
content: {
'application/json': {
schema: SandboxListRecursiveResponseSchema
}
}
}
}
}
},
'/core/ai/sandbox/write': {
post: {
summary: '写入沙盒文件',
description: '将内容写入指定路径的文件',
tags: [TagsMap.sandbox],
requestBody: {
content: {
'application/json': {
schema: SandboxWriteBodySchema
}
}
},
responses: {
200: {
description: '写入成功',
content: {
'application/json': {
schema: SandboxWriteResponseSchema
}
}
}
}
}
},
'/core/ai/sandbox/read': {
post: {
summary: '读取沙盒文件内容',
description: '读取文件内容并以对应 MIME 类型内联返回,适用于预览场景',
tags: [TagsMap.sandbox],
requestBody: {
content: {
'application/json': {
schema: SandboxReadBodySchema
}
}
},
responses: {
200: {
content: {
'*/*': {
schema: SandboxReadResponseSchema
}
}
}
}
}
},
'/core/ai/sandbox/download': {
post: {
summary: '下载沙盒文件或目录',
......@@ -193,24 +86,24 @@ export const SandboxPath: OpenAPIPath = {
}
},
'/core/ai/sandbox/fileOp': {
'/core/ai/sandbox/getTicket': {
post: {
summary: '文件系统操作',
description: '在沙盒中执行创建目录、删除、移动和复制等文件系统操作',
summary: '获取沙盒 WebSocket 临时凭证',
description: '鉴权并返回用于连接 agent-sandbox-proxy 的短期 ticket',
tags: [TagsMap.sandbox],
requestBody: {
content: {
'application/json': {
schema: SandboxFileOpBodySchema
schema: SandboxGetTicketBodySchema
}
}
},
responses: {
200: {
description: '操作成功',
description: '返回沙盒 WebSocket 临时凭证',
content: {
'application/json': {
schema: SandboxFileOpResponseSchema
schema: SandboxGetTicketResponseSchema
}
}
}
......
......@@ -317,8 +317,7 @@ export const GetSkillFolderPathResponseSchema = z.array(
export type GetSkillFolderPathResponse = z.infer<typeof GetSkillFolderPathResponseSchema>;
export const ExportSkillQuerySchema = z.object({
skillId: IdSchema,
source: z.enum(['version', 'workspace']).optional().default('version')
skillId: IdSchema
});
export type ExportSkillQuery = z.infer<typeof ExportSkillQuerySchema>;
......
......@@ -165,15 +165,15 @@ export const SkillPath: OpenAPIPath = {
},
'/core/ai/skill/export': {
get: {
summary: '导出技能',
description: '下载技能 ZIP 包',
summary: '导出技能编辑区',
description: '下载当前技能编辑沙盒工作区 ZIP 包',
tags: [TagsMap.aiSkill],
requestParams: {
query: ExportSkillQuerySchema
},
responses: {
200: {
description: '返回技能 zip 文件',
description: '返回技能编辑区 zip 文件',
content: {
'application/zip': {
schema: {
......
......@@ -207,5 +207,16 @@ export const pushTrack = {
retentionDays: data.retentionDays
}
});
},
sandboxArchive: (data: {
provider: string;
sandboxId: string;
reason: string;
source?: string;
}) => {
return createTrack({
event: TrackEnum.sandboxArchive,
data
});
}
};
import type { S3SandboxSource } from '.';
declare global {
var sandboxBucket: S3SandboxSource;
}
export {};
import type { Readable } from 'node:stream';
import { S3PrivateBucket } from '../../buckets/private';
import { readStreamToBuffer } from '../../utils';
const SANDBOX_WORKSPACE_ARCHIVE_FILENAME = 'package.zip';
const getWorkspaceArchiveKey = (sandboxId: string): string =>
`agent-sandbox/${sandboxId}/${SANDBOX_WORKSPACE_ARCHIVE_FILENAME}`;
export class S3SandboxSource extends S3PrivateBucket {
constructor() {
super();
}
async uploadWorkspaceArchive(params: { sandboxId: string; body: Buffer | string | Readable }) {
await this.client.uploadObject({
key: getWorkspaceArchiveKey(params.sandboxId),
body: params.body,
contentType: 'application/zip',
metadata: {
uploadTime: new Date().toISOString(),
originFilename: encodeURIComponent(SANDBOX_WORKSPACE_ARCHIVE_FILENAME)
}
});
}
async downloadWorkspaceArchive(params: {
sandboxId: string;
maxBytes?: number;
}): Promise<Buffer> {
const key = getWorkspaceArchiveKey(params.sandboxId);
const response = await this.client.downloadObject({ key });
if (!response.body) {
throw new Error(`Failed to download sandbox archive: ${params.sandboxId}`);
}
return readStreamToBuffer({
stream: response.body,
maxBytes: params.maxBytes,
exceededMessage:
params.maxBytes === undefined
? undefined
: `Sandbox archive exceeds maximum allowed size (${params.maxBytes} bytes)`
});
}
deleteWorkspaceArchive(params: { sandboxId: string }) {
return this.addDeleteJob({
key: getWorkspaceArchiveKey(params.sandboxId)
});
}
}
export function getS3SandboxSource() {
if (global.sandboxBucket) {
return global.sandboxBucket;
}
global.sandboxBucket = new S3SandboxSource();
return global.sandboxBucket;
}
import { isAfter } from 'date-fns';
import type { ClientSession } from 'mongoose';
import { buffer as consumeStreamToBuffer } from 'node:stream/consumers';
import type { Readable } from 'node:stream';
import { MongoS3TTL } from './models/ttl';
import { S3Buckets } from './config/constants';
import { S3PrivateBucket } from './buckets/private';
......@@ -16,6 +18,43 @@ export { jwtSignS3ObjectKey, jwtVerifyS3ObjectKey, jwtSignS3DownloadToken } from
export const S3_FILENAME_MAX_LENGTH = 50;
/**
* 将 S3 下载流读取为 Buffer。
*
* 普通小文件可以直接用 node:stream/consumers;但 archive/Skill 包这类受环境变量限制的对象,
* 需要在读取过程中按 chunk 检查上限并提前销毁流,避免异常对象被完整读入内存。
*/
export async function readStreamToBuffer(params: {
stream: Readable;
maxBytes?: number;
exceededMessage?: string;
}): Promise<Buffer> {
const { stream, maxBytes, exceededMessage } = params;
if (maxBytes === undefined) {
return consumeStreamToBuffer(stream);
}
const chunks: Buffer[] = [];
let totalSize = 0;
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalSize += buffer.length;
if (totalSize > maxBytes) {
stream.destroy();
throw new Error(
exceededMessage ?? `S3 object exceeds maximum allowed size (${maxBytes} bytes)`
);
}
chunks.push(buffer);
}
return Buffer.concat(chunks, totalSize);
}
/**
* 截断文件名,确保不超过最大长度,同时保留扩展名
* @param filename 原始文件名
* @param maxLength 最大长度限制
......
......@@ -14,6 +14,7 @@ export enum TimerIdEnum {
auditLogCleanup = 'auditLogCleanup',
chatHistoryCleanup = 'chatHistoryCleanup',
datasetSyncSchedulerReconcile = 'datasetSyncSchedulerReconcile',
archiveInactiveSandboxes = 'archiveInactiveSandboxes',
/** 纠正长时间卡在 generating 的会话状态 */
cleanStaleGeneratingChat = 'cleanStaleGeneratingChat'
}
......
......@@ -24,7 +24,10 @@ export const initFastGPTConfig = (config?: FastGPTConfigFileType) => {
config.feConfigs.uploadFileMaxAmount = serviceEnv.UPLOAD_FILE_MAX_AMOUNT;
config.feConfigs.limit = {
...config.feConfigs.limit,
agentSkillMaxUploadBytes: serviceEnv.AGENT_SKILL_MAX_UPLOAD_SIZE * 1024 * 1024,
agentSandboxMaxEditDebug: serviceEnv.AGENT_SANDBOX_MAX_EDIT_DEBUG,
agentSandboxArchiveMaxBytes: serviceEnv.AGENT_SANDBOX_ARCHIVE_MAX_SIZE * 1024 * 1024,
skillSandboxMaxBytes: serviceEnv.AGENT_SANDBOX_SKILL_MAX_SIZE * 1024 * 1024,
agentSandboxMaxFileBytes: serviceEnv.AGENT_SANDBOX_MAX_FILE_SIZE * 1024 * 1024,
maxFolderDepth: serviceEnv.MAX_FOLDER_DEPTH
};
......
......@@ -34,6 +34,7 @@ export const getVlmModel = (model?: string) => {
export const getDefaultHelperBotModel = (): LLMModelItemType =>
global?.systemDefaultModel.helperBotLLM || getDefaultLLMModel();
export const getSkillCreationLLMModel = () => getDefaultLLMModel().model;
export const getDefaultEmbeddingModel = () => global?.systemDefaultModel.embedding!;
export const getEmbeddingModel = (model?: string | EmbeddingModelItemType) => {
if (!model) return getDefaultEmbeddingModel();
......
......@@ -72,7 +72,7 @@ SandboxInstanceSchema.index(
}
}
);
SandboxInstanceSchema.index({ status: 1, lastActiveAt: 1 });
SandboxInstanceSchema.index({ status: 1, lastActiveAt: 1, 'metadata.archive.state': 1 });
SandboxInstanceSchema.index({ provider: 1, sandboxId: 1 }, { unique: true });
SandboxInstanceSchema.index(
{ appId: 1, chatId: 1 },
......
import { serviceEnv } from '../../../../env';
import {
createSandbox,
type ISandbox,
......@@ -35,8 +34,6 @@ export function buildSandboxAdapter(
baseUrl: providerConfig.baseUrl,
runtime: providerConfig.runtime,
useServerProxy: providerConfig.useServerProxy,
replaceDockerInternalWithLocalhost:
serviceEnv.SANDBOX_PROXY_REPLACE_DOCKER_INTERNAL_WITH_LOCALHOST,
sessionId: props.sandboxId
},
props.createConfig
......
import { serviceEnv } from '../../../../env';
import type { SandboxCreateSpec, SandboxProviderType } from '@fastgpt-sdk/sandbox-adapter';
import type { VolumeManagerResult } from '../volume/service';
import { getSandboxRuntimeProfile } from '../runtime/profile';
import { getSandboxRuntimeProfile, buildBaseSandboxRuntimeEnv } from '../runtime/profile';
type SandboxRuntime = 'kubernetes' | 'docker';
......@@ -40,6 +40,14 @@ function assertNever(value: never): never {
throw new Error(`Unsupported sandbox provider: ${String(value)}`);
}
export function getConfiguredSandboxProvider(): SandboxProviderType {
const provider = serviceEnv.AGENT_SANDBOX_PROVIDER;
if (!provider) {
throw new Error('AGENT_SANDBOX_PROVIDER is required when Agent Sandbox is used');
}
return provider;
}
/**
* 获取当前或指定 provider 的连接配置。
*
......@@ -47,7 +55,7 @@ function assertNever(value: never): never {
* 适合权限校验、历史资源查询等只需要 provider 名称的场景。
*/
export function getSandboxProviderConfig(
provider: SandboxProviderType = serviceEnv.AGENT_SANDBOX_PROVIDER
provider: SandboxProviderType = getConfiguredSandboxProvider()
): SandboxProviderConfig {
return getSandboxAdapterConfig({ provider }).providerConfig;
}
......@@ -86,7 +94,7 @@ export function validateSandboxConfig(config: SandboxProviderConfig): void {
* SandboxRuntimeProfile.buildConfig,避免这里再散落 provider 运行时分支。
*/
export function getSandboxAdapterConfig({
provider = serviceEnv.AGENT_SANDBOX_PROVIDER,
provider = getConfiguredSandboxProvider(),
runtime = false,
resourceLimits,
vmConfig,
......@@ -100,6 +108,17 @@ export function getSandboxAdapterConfig({
createConfig?: SandboxCreateConfig;
sessionId?: string;
} = {}): SandboxAdapterConfig {
const profile = getSandboxRuntimeProfile(provider);
const baseEnv =
runtime && sessionId
? buildBaseSandboxRuntimeEnv({
sessionId,
workDirectory: profile.workDirectory,
ideAgentBindAddr: serviceEnv.IDE_AGENT_BIND_ADDR,
ideAgentMaxFileBytes: serviceEnv.AGENT_SANDBOX_MAX_FILE_SIZE * 1024 * 1024
})
: undefined;
switch (provider) {
case 'opensandbox': {
const providerConfig: OpenSandboxProviderConfig = {
......@@ -114,10 +133,15 @@ export function getSandboxAdapterConfig({
return {
providerConfig,
createConfig: runtime
? getSandboxRuntimeProfile(provider).buildConfig({
? profile.buildConfig({
resourceLimits,
vmConfig,
createConfig
createConfig,
entrypoint: createConfig?.entrypoint ?? (profile.entrypoint || undefined),
env: {
...createConfig?.env,
...baseEnv
}
})
: undefined
};
......@@ -134,9 +158,13 @@ export function getSandboxAdapterConfig({
return {
providerConfig,
createConfig: runtime
? getSandboxRuntimeProfile(provider).buildConfig({
? profile.buildConfig({
createConfig,
sessionId
sessionId,
env: {
...createConfig?.env,
...baseEnv
}
})
: undefined
};
......@@ -152,7 +180,7 @@ export function getSandboxAdapterConfig({
return {
providerConfig,
createConfig: runtime
? getSandboxRuntimeProfile(provider).buildConfig({
? profile.buildConfig({
createConfig
})
: undefined
......
import { getLogger, LogCategories } from '../../../../common/logger';
import { type ISandbox, type OpenSandboxAdapter } from '@fastgpt-sdk/sandbox-adapter';
import {
type ISandbox,
type OpenSandboxAdapter,
type SandboxCreateSpec
} from '@fastgpt-sdk/sandbox-adapter';
import { buildSandboxAdapter } from './adapter';
import type { SandboxProviderConfig } from './config';
......@@ -7,7 +11,7 @@ const logger = getLogger(LogCategories.MODULE.AI.SANDBOX);
export type SandboxInfo = NonNullable<Awaited<ReturnType<ISandbox['getInfo']>>>;
const SANDBOX_COMMAND_READY_TIMEOUT_MS = 120_000;
const SANDBOX_COMMAND_READY_TIMEOUT_MS = 300_000;
const SANDBOX_COMMAND_READY_INTERVAL_MS = 1_000;
const SANDBOX_COMMAND_READY_PROBE_TIMEOUT_MS = 5_000;
......@@ -92,10 +96,12 @@ async function waitUntilSandboxCommandReady(
*/
export async function connectToSandbox(
providerConfig: SandboxProviderConfig,
sandboxId: string
sandboxId: string,
createConfig?: SandboxCreateSpec
): Promise<ISandbox> {
const sandbox = buildSandboxAdapter(providerConfig, {
sandboxId
sandboxId,
createConfig
});
await ensureConnectedSandboxRunning(sandbox);
......@@ -128,7 +134,7 @@ export async function getReadySandboxInfo(
sandbox: ISandbox,
fallback: {
sandboxId: string;
image: SandboxInfo['image'];
image?: SandboxInfo['image'];
entrypoint?: SandboxInfo['entrypoint'];
status?: SandboxInfo['status'];
createdAt?: SandboxInfo['createdAt'];
......@@ -147,7 +153,7 @@ export async function getReadySandboxInfo(
return {
id: sandbox.id ?? fallback.sandboxId,
image: fallback.image,
...(fallback.image ? { image: fallback.image } : {}),
entrypoint: fallback.entrypoint ?? [],
status: fallback.status ?? sandbox.status,
createdAt: fallback.createdAt ?? new Date()
......@@ -163,17 +169,17 @@ export async function connectReadySandboxByInstance(
providerConfig: SandboxProviderConfig,
instance: {
sandboxId: string;
}
},
createConfig?: SandboxCreateSpec
): Promise<{
sandbox: ISandbox;
sandboxInfo: SandboxInfo;
}> {
const sandbox = await connectToSandbox(providerConfig, instance.sandboxId);
const sandbox = await connectToSandbox(providerConfig, instance.sandboxId, createConfig);
try {
const sandboxInfo = await getReadySandboxInfo(sandbox, {
sandboxId: instance.sandboxId,
image: { repository: '' },
status: sandbox.status
});
return {
......
......@@ -11,9 +11,6 @@ const E2B_DEFAULT_WORK_DIRECTORY = '/home/user';
export function buildE2BRuntimeProfile(): SandboxRuntimeProfile {
return {
provider: 'e2b',
defaultImage: {
repository: ''
},
workDirectory: E2B_DEFAULT_WORK_DIRECTORY,
entrypoint: '',
skillsRootPath: getSandboxSkillsRootPath(E2B_DEFAULT_WORK_DIRECTORY),
......
......@@ -16,6 +16,14 @@ function assertNever(value: never): never {
throw new Error(`Unsupported sandbox provider: ${String(value)}`);
}
function getConfiguredProvider(): SandboxProviderType {
const provider = serviceEnv.AGENT_SANDBOX_PROVIDER;
if (!provider) {
throw new Error('AGENT_SANDBOX_PROVIDER is required when Agent Sandbox is used');
}
return provider;
}
/**
* 获取 provider 对应的 FastGPT sandbox 运行态契约。
*
......@@ -23,7 +31,7 @@ function assertNever(value: never): never {
* 映射由各 provider profile 文件维护。
*/
export function getSandboxRuntimeProfile(
provider: SandboxProviderType = serviceEnv.AGENT_SANDBOX_PROVIDER
provider: SandboxProviderType = getConfiguredProvider()
): SandboxRuntimeProfile {
switch (provider) {
case 'opensandbox':
......
import { serviceEnv } from '../../../../../env';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
import type { SandboxRuntimeProfile } from './types';
import { getSandboxSkillsRootPath, mergeStringRecord, mergeUnknownRecord } from './utils';
const SEALOS_DEFAULT_WORK_DIRECTORY = '/home/devbox/workspace';
import { parseImageSpec } from '@fastgpt-sdk/sandbox-adapter';
/**
* 构建 Sealos Devbox 的 FastGPT 运行态 profile。
......@@ -10,22 +10,19 @@ const SEALOS_DEFAULT_WORK_DIRECTORY = '/home/devbox/workspace';
* Devbox 的工作目录通过 CODEX_GATEWAY_CWD 间接生效,adapter 会把 workingDir 映射过去。
*/
export function buildSealosRuntimeProfile(): SandboxRuntimeProfile {
const workDirectory =
serviceEnv.AGENT_SANDBOX_SEALOS_WORK_DIRECTORY ?? SEALOS_DEFAULT_WORK_DIRECTORY;
const workDirectory = serviceEnv.AGENT_SANDBOX_SEALOS_WORK_DIRECTORY || '/home/devbox/workspace';
const defaultImage = parseImageSpec(serviceEnv.AGENT_SANDBOX_SEALOS_IMAGE);
return {
provider: 'sealosdevbox',
defaultImage: {
repository: ''
},
defaultImage,
workDirectory,
entrypoint: '',
skillsRootPath: getSandboxSkillsRootPath(workDirectory),
buildConfig(input = {}) {
const createConfig = input.createConfig ?? {};
// edit-debug 复用 Devbox 模板环境,不允许用编辑态镜像覆盖;普通运行态仍可显式指定镜像。
const image =
createConfig.image ?? (input.scenario === 'edit-debug' ? undefined : input.image);
const image = input.image ?? createConfig.image ?? defaultImage;
const env = mergeStringRecord(createConfig.env, input.env);
const metadata = mergeUnknownRecord(createConfig.metadata, input.metadata);
// Sealos adapter 会把 workingDir 写入 CODEX_GATEWAY_CWD,让 exec/code-server 落在同一工作区。
......
......@@ -27,11 +27,11 @@ export type SandboxRuntimeCreateConfigInput = {
* FastGPT 对某个 sandbox provider 的运行态契约。
*
* provider 连接认证仍由 provider/config.ts 负责;这里只维护工作目录、技能根目录、
* 默认镜像、入口脚本,以及统一 createConfig 到 provider create spec 的转换。
* 可选默认镜像、入口脚本,以及统一 createConfig 到 provider create spec 的转换。
*/
export type SandboxRuntimeProfile = {
provider: SandboxProviderType;
defaultImage: SandboxImageConfigType;
defaultImage?: SandboxImageConfigType;
workDirectory: string;
entrypoint: string;
skillsRootPath: string;
......
......@@ -50,12 +50,22 @@ export const normalizeEntrypoint = (entrypoint?: string | string[]) => {
*
* 这些变量表达 FastGPT 自身的运行契约,provider 只负责把它们映射到实际 createConfig。
*/
export function buildBaseSandboxRuntimeEnv(
sessionId: string,
workDirectory: string
): Record<string, string> {
export function buildBaseSandboxRuntimeEnv({
sessionId,
workDirectory,
ideAgentBindAddr,
ideAgentMaxFileBytes
}: {
sessionId: string;
workDirectory: string;
ideAgentBindAddr: string;
ideAgentMaxFileBytes: number;
}): Record<string, string> {
return {
FASTGPT_SESSION_ID: sessionId,
FASTGPT_WORKDIR: workDirectory
FASTGPT_WORKDIR: workDirectory,
IDE_AGENT_ENABLED: 'true',
IDE_AGENT_BIND_ADDR: ideAgentBindAddr,
FASTGPT_IDE_MAX_FILE_BYTES: String(ideAgentMaxFileBytes)
};
}
......@@ -4,6 +4,9 @@ import { setCron } from '../../../../common/system/cron';
import { subMinutes } from 'date-fns';
import { findInactiveRunningSandboxResources } from '../instance/repository';
import { stopSandboxResources } from './resource';
import { checkTimerLock } from '../../../../common/system/timerLock/utils';
import { TimerIdEnum } from '../../../../common/system/timerLock/constants';
import { archiveInactiveSandboxes } from './archive';
const logger = getLogger(LogCategories.MODULE.AI.SANDBOX);
......@@ -24,4 +27,16 @@ export const cronJob = async () => {
await stopSandboxResources(instances);
});
setCron('0 */12 * * *', async () => {
const locked = await checkTimerLock({
timerId: TimerIdEnum.archiveInactiveSandboxes,
lockMinuted: 11 * 60
});
if (!locked) return;
await archiveInactiveSandboxes().catch((error) => {
logger.error('Sandbox archive cron failed', { error });
});
});
};
......@@ -9,6 +9,7 @@ import {
} from '../instance/repository';
import { buildSandboxResourceAdapter } from '../provider/adapter';
import { deleteSessionVolume } from '../volume/service';
import { getS3SandboxSource } from '../../../../common/s3/sources/sandbox';
const logger = getLogger(LogCategories.MODULE.AI.SANDBOX);
......@@ -22,23 +23,44 @@ export async function stopSandboxResource(resource: SandboxResourceRef): Promise
const sandbox = buildSandboxResourceAdapter(resource);
await sandbox.stop();
await markSandboxResourceStopped(resource);
const stoppedResult = await markSandboxResourceStopped(resource);
if (resource.lastActiveAt && stoppedResult?.matchedCount === 0) {
logger.warn('Skip marking sandbox stopped because record changed after stop', {
sandboxId: resource.sandboxId,
provider: resource.provider
});
}
}
/**
* 删除一条已存在的 sandbox 资源记录,并尽力清理关联 volume。
*/
export async function deleteSandboxResource(resource: SandboxResourceRef): Promise<void> {
export async function deleteSandboxResource(
resource: SandboxResourceRef,
opts: { keepVolume?: boolean } = {}
): Promise<void> {
const sandbox = buildSandboxResourceAdapter(resource);
await sandbox.delete();
if (!opts.keepVolume && resource.provider === 'opensandbox') {
await deleteSessionVolume(resource.sandboxId).catch((err) => {
logger.error('Failed to delete sandbox volume', {
sandboxId: resource.sandboxId,
error: err
});
});
}
await deleteSandboxResourceRecord(resource);
await getS3SandboxSource()
.deleteWorkspaceArchive({
sandboxId: resource.sandboxId
})
.catch((err) => {
logger.error('Failed to delete sandbox archive', {
sandboxId: resource.sandboxId,
error: err
});
});
}
/**
......
import { generateSandboxId } from '@fastgpt/global/core/ai/sandbox/constants';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { serviceEnv } from '../../../../env';
import { getLogger, LogCategories } from '../../../../common/logger';
import {
type ExecuteResult,
......@@ -10,19 +9,30 @@ import {
} from '@fastgpt-sdk/sandbox-adapter';
import { getSessionVolumeConfig, type VolumeManagerResult } from '../volume/service';
import { buildRuntimeSandboxAdapter } from '../provider/adapter';
import { getConfiguredSandboxProvider } from '../provider/config';
import { ensureConnectedSandboxRunning } from '../provider/lifecycle';
import { deleteSandboxResource, stopSandboxResource } from './resource';
import { upsertRunningSandboxInstance } from '../instance/repository';
import type { SandboxProviderType } from '../type';
import {
assertSandboxNotArchivedOrBusy,
SandboxArchiveStateError,
restoreArchivedSandboxBeforeUse
} from './archive';
const logger = getLogger(LogCategories.MODULE.AI.SANDBOX);
type UnionIdType = {
export type SandboxClientQuery =
| {
sandboxId: string;
teamId?: string;
}
| {
appId: string;
userId: string;
userId?: string;
chatId: string;
teamId?: string;
};
};
type SandboxClientProps = {
sandboxId: string;
......@@ -37,8 +47,11 @@ type SandboxClientOptions = {
resourceLimits?: ResourceLimits;
vmConfig?: VolumeManagerResult | undefined;
createConfig?: SandboxCreateSpec;
restoreArchived?: boolean;
};
type NormalizedSandboxClientQuery = SandboxClientProps;
/**
* 当前会话运行态 sandbox client。
*
......@@ -62,7 +75,7 @@ export class SandboxClient {
this.userId = props.userId;
this.chatId = props.chatId;
this.providerName = opts.providerName ?? serviceEnv.AGENT_SANDBOX_PROVIDER;
this.providerName = opts.providerName ?? getConfiguredSandboxProvider();
this.provider = buildRuntimeSandboxAdapter(this.providerName, this.sandboxId, opts);
}
......@@ -75,7 +88,7 @@ export class SandboxClient {
async ensureAvailable() {
// 先写 running 记录是有意设计:运行态入口需要先占位并暴露资源归属,
// 后续 provider ready 检查失败时会由调用方返回错误,后台兜底检查/cron 再修正不可用实例。
await upsertRunningSandboxInstance({
const instance = await upsertRunningSandboxInstance({
provider: this.providerName,
sandboxId: this.sandboxId,
appId: this.appId,
......@@ -93,6 +106,13 @@ export class SandboxClient {
volumeEnabled: !!this.opts?.vmConfig
}
});
if (!instance) {
await assertSandboxNotArchivedOrBusy({
provider: this.providerName,
sandboxId: this.sandboxId
});
throw new SandboxArchiveStateError('archiving');
}
await ensureConnectedSandboxRunning(this.provider);
}
......@@ -152,21 +172,33 @@ export class SandboxClient {
}
}
type ExplicitSandboxIdType = {
sandboxId: string;
appId?: string;
userId?: string;
chatId?: string;
teamId?: string;
};
export function resolveSandboxId(props: SandboxClientQuery): string {
return normalizeSandboxClientQuery(props).sandboxId;
}
function resolveSandboxId(props: ExplicitSandboxIdType | UnionIdType): string {
function normalizeSandboxClientQuery(props: SandboxClientQuery): NormalizedSandboxClientQuery {
if ('sandboxId' in props) {
return props.sandboxId;
if (!props.sandboxId) {
throw new Error('sandboxId is required');
}
return {
sandboxId: props.sandboxId,
teamId: props.teamId
};
}
const sandboxUserId = props.chatId === 'edit-debug' ? '' : props.userId;
return generateSandboxId(props.appId, sandboxUserId, props.chatId);
if (!props.appId || !props.chatId) {
throw new Error('appId and chatId are required when sandboxId is not provided');
}
const sandboxUserId = props.chatId === 'edit-debug' ? '' : (props.userId ?? '');
return {
sandboxId: generateSandboxId(props.appId, sandboxUserId, props.chatId),
appId: props.appId,
userId: props.userId,
chatId: props.chatId,
teamId: props.teamId
};
}
/**
......@@ -176,18 +208,45 @@ function resolveSandboxId(props: ExplicitSandboxIdType | UnionIdType): string {
* 返回前会准备 volume 配置并确保 sandbox 可用。
*/
export const getSandboxClient = async (
props: ExplicitSandboxIdType | UnionIdType,
opts: {
providerName?: SandboxProviderType;
resourceLimits?: ResourceLimits;
createConfig?: SandboxCreateSpec;
} = {}
props: SandboxClientQuery,
opts: Omit<SandboxClientOptions, 'vmConfig'> = {}
) => {
const sandboxId = resolveSandboxId(props);
const vmConfig = await getSessionVolumeConfig(sandboxId);
const sandbox = new SandboxClient({ ...props, sandboxId }, { ...opts, vmConfig });
const sandboxContext = normalizeSandboxClientQuery(props);
const { sandboxId, appId, userId, chatId, teamId } = sandboxContext;
const providerName = opts.providerName ?? getConfiguredSandboxProvider();
let vmConfig: VolumeManagerResult | undefined;
if (opts.restoreArchived === false) {
await assertSandboxNotArchivedOrBusy({
provider: providerName,
sandboxId
});
} else {
vmConfig = providerName === 'opensandbox' ? await getSessionVolumeConfig(sandboxId) : undefined;
await restoreArchivedSandboxBeforeUse({
provider: providerName,
sandboxId,
appId,
userId,
chatId,
resourceLimit: opts.resourceLimits
? {
cpuCount: opts.resourceLimits.cpuCount,
memoryMiB: opts.resourceLimits.memoryMiB,
diskGiB: opts.resourceLimits.diskGiB
}
: undefined,
vmConfig: vmConfig ?? null,
storage: vmConfig?.storage,
createConfig: opts.createConfig
});
}
vmConfig ??= providerName === 'opensandbox' ? await getSessionVolumeConfig(sandboxId) : undefined;
const sandbox = new SandboxClient(sandboxContext, {
...opts,
providerName,
vmConfig
});
await sandbox.ensureAvailable();
return sandbox;
};
......@@ -32,17 +32,29 @@ export const SandboxImageSchema = z.object({
tag: z.string().optional()
});
export const SandboxArchiveStateSchema = z.enum(['archiving', 'archived', 'restoring']);
export type SandboxArchiveStateType = z.infer<typeof SandboxArchiveStateSchema>;
export const SandboxMetadataSchema = z.object({
teamId: z.string().optional(),
tmbId: z.string().optional(),
volumeEnabled: z.boolean().optional(),
provider: SandboxProviderSchema.optional(),
archive: z
.object({
state: SandboxArchiveStateSchema,
archivedAt: z.coerce.date().optional()
})
.optional(),
skillId: z.string().optional(),
sessionId: z.string().optional(),
skillIds: z.array(z.string()).optional(),
image: SandboxImageSchema
image: SandboxImageSchema.optional(),
skillName: z.string().optional(),
versionId: z.string().optional()
});
export type SandboxMetadataType = z.infer<typeof SandboxMetadataSchema>;
......
......@@ -2,7 +2,7 @@ import {
OPEN_SANDBOX_DEFAULT_ROOT_PATH,
type OpenSandboxConfigType
} from '@fastgpt-sdk/sandbox-adapter';
import type { SandboxStorageType } from '../type';
import type { SandboxProviderType, SandboxStorageType } from '../type';
import { getVolumeManagerEnvConfig } from './config';
export type VolumeManagerResult = {
......@@ -11,6 +11,13 @@ export type VolumeManagerResult = {
};
/**
* 判断指定 provider 是否需要 FastGPT 自管 volume。
*
* Sealos Devbox 的工作区持久化由 provider 内部管理;只有 OpenSandbox 需要 FastGPT
* volume-manager 参与挂载和清理。
*/
/**
* 将 volume-manager 返回的 PVC 名称转换成 OpenSandbox adapter 可识别的卷配置。
*
* OpenSandbox 的持久化工作区固定为 /workspace,避免 env 配置和镜像契约分叉。
......
import { mongoSessionRun } from '../../../../common/mongo/sessionRun';
import { Types } from '../../../../common/mongo';
import { getLogger, LogCategories } from '../../../../common/logger';
import { updateCurrentVersion } from '../manage';
import { removeSkillPackageTTL, validateZipStructure, uploadSkillPackage } from '../package';
import { removeSkillPackageTTL, uploadSkillPackage } from '../package';
import { packageSkillInSandbox } from './sandbox';
import { EDIT_DEBUG_SANDBOX_CHAT_ID } from './config';
import { createVersion } from '../version';
import { getSandboxRuntimeProfile } from '../../sandbox/runtime/profile';
import { getSandboxProviderConfig } from '../../sandbox/provider/config';
import { findSandboxInstanceByAppChatType } from '../../sandbox/instance/repository';
import {
findSandboxInstanceByAppChatType,
updateSandboxInstanceRecordBySandboxId
} from '../../sandbox/instance/repository';
import { MongoAgentSkills } from '../model/schema';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants';
......@@ -16,6 +20,8 @@ import { UserError } from '@fastgpt/global/common/error/utils';
import type { SaveDeploySkillResponse } from '@fastgpt/global/core/ai/skill/api';
import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time';
const logger = getLogger(LogCategories.MODULE.AI.SANDBOX);
export type SaveDeploySkillFromSandboxParams = {
skillId: string;
teamId: string;
......@@ -55,10 +61,6 @@ export async function saveDeploySkillFromSandbox({
sandboxId: sandboxInfo.sandboxId,
workDirectory: runtimeProfile.workDirectory
});
const validation = await validateZipStructure(packageBuffer);
if (!validation.valid) {
throw new Error(validation.error || 'Invalid skill package structure');
}
} catch (error: any) {
return Promise.reject(
new UserError(`Failed to package skill directory: ${error.message || 'Unknown error'}`)
......@@ -84,7 +86,7 @@ export async function saveDeploySkillFromSandbox({
throw new UserError(`Failed to upload package: ${error.message || 'Unknown error'}`);
}
return mongoSessionRun(async (session) => {
const deployResult = await mongoSessionRun(async (session) => {
const isVersionLinked = await updateCurrentVersion(skillId, versionId, session);
if (!isVersionLinked) {
// skill 可能在打包上传期间被删除。此时不能移除 S3 TTL,让孤儿包继续走 TTL 清理。
......@@ -120,4 +122,22 @@ export async function saveDeploySkillFromSandbox({
createdAt: createdAt.toISOString()
};
});
// 发布新版本成功后,更新运行中沙盒实例的 versionId,保证后续版本切换时能够正确执行版本比对和容器重建
await updateSandboxInstanceRecordBySandboxId({
provider: providerConfig.provider,
sandboxId: sandboxInfo.sandboxId,
metadata: {
...(sandboxInfo.metadata || {}),
versionId
}
}).catch((err) => {
logger.error('[Sandbox] Failed to update sandbox versionId after deploy', {
sandboxId: sandboxInfo.sandboxId,
versionId,
error: err
});
});
return deployResult;
}
......@@ -23,7 +23,7 @@ import { getLogger, LogCategories } from '../../../../../common/logger';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { AgentSkillCreationStatusEnum } from '@fastgpt/global/core/ai/skill/constants';
import { createSkillGenerationUsage } from './usage';
import { getSkillCreationLLMModel } from './model';
import { getSkillCreationLLMModel } from '../../../model';
const logger = getLogger(LogCategories.MODULE.AGENT_SKILLS.CREATION);
......
import { getDefaultHelperBotModel } from '../../../model';
/**
* 选择 AI 辅助创建 Skill 使用的 LLM 模型。
*
* 与 HelperBot 辅助生成保持同一配置入口:优先使用 HELPER_BOT_MODEL 命中的
* 已启用模型;未配置或未命中时由系统默认 LLM 兜底。
*/
export const getSkillCreationLLMModel = () => getDefaultHelperBotModel().model;
......@@ -6,9 +6,10 @@
*/
import { getS3SkillSource } from '../../../../common/s3/sources/skill';
import { getSkillSizeLimits } from '../sandbox/config';
import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill';
import type { ClientSession } from '../../../../common/mongo';
import { serviceEnv } from '../../../../env';
import { readStreamToBuffer } from '../../../../common/s3/utils';
export type SkillStorageInfo = {
key: string;
......@@ -34,9 +35,9 @@ export async function uploadSkillPackage(
params: UploadSkillPackageParams
): Promise<SkillStorageInfo> {
const { teamId, skillId, packageObjectId, zipBuffer } = params;
const { maxUploadBytes } = getSkillSizeLimits();
const maxBytes = serviceEnv.AGENT_SANDBOX_SKILL_MAX_SIZE * 1024 * 1024;
if (zipBuffer.length > maxUploadBytes) {
if (zipBuffer.length > maxBytes) {
throw new Error(SkillErrEnum.archiveTooLarge);
}
......@@ -69,11 +70,11 @@ export async function removeSkillPackageTTL(
/**
* 从私有对象存储下载 Skill ZIP 包。
*
* 下载过程中按流式累计大小,超过 sandbox 配置的上限即中断,避免异常大包撑爆内存。
* 下载过程中按流式累计大小,超过 Skill sandbox 包大小上限即中断,避免异常大包撑爆内存。
*/
export async function downloadSkillPackage(params: DownloadSkillPackageParams): Promise<Buffer> {
const { storageKey } = params;
const { maxDownloadBytes } = getSkillSizeLimits();
const maxBytes = serviceEnv.AGENT_SANDBOX_SKILL_MAX_SIZE * 1024 * 1024;
const bucket = getS3SkillSource();
......@@ -85,21 +86,11 @@ export async function downloadSkillPackage(params: DownloadSkillPackageParams):
throw new Error(`Failed to download skill package: ${storageKey}`);
}
// 流式累加并检查上限,防止异常对象导致 OOM。
const chunks: Buffer[] = [];
let totalSize = 0;
for await (const chunk of response.body) {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalSize += buf.length;
if (totalSize > maxDownloadBytes) {
throw new Error(
`Skill package exceeds maximum allowed size (${maxDownloadBytes / 1024 / 1024}MB)`
);
}
chunks.push(buf);
}
return Buffer.concat(chunks);
return readStreamToBuffer({
stream: response.body,
maxBytes,
exceededMessage: `Skill package exceeds maximum allowed size (${maxBytes / 1024 / 1024}MB)`
});
}
/**
......
......@@ -17,14 +17,10 @@ import JSZip from 'jszip';
import { extractSkillNameFromSkillMd } from '../utils';
import { DEFAULT_GITIGNORE_CONTENT } from './constants';
// 测试用例需要直接构造 ZIP,因此这里保留 JSZip 的再导出。
export { JSZip };
export type CreateSkillPackageParams = {
name: string;
skillMd: string;
assets?: Record<string, Buffer | string>;
additionalFiles?: Record<string, Buffer | string>;
};
/**
......@@ -47,6 +43,7 @@ export type ZipValidationResult = {
files: string[];
error?: string;
skillMdPath?: string;
totalUncompressedBytes?: number;
};
export type ExtractSkillPackageResult = {
......@@ -67,7 +64,7 @@ export type NormalizedSkillPackageFile = {
* 输出结构固定为 `{name}/SKILL.md` 加可选资源文件,便于后续版本存储和导出保持一致。
*/
export async function createSkillPackage(params: CreateSkillPackageParams): Promise<Buffer> {
const { name, skillMd, assets, additionalFiles } = params;
const { name, skillMd, assets } = params;
const zip = new JSZip();
// 根目录名直接来自 skill name,前面流程已经做过合法性约束。
......@@ -79,26 +76,22 @@ 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']));
// 只有根目录下显式声明了 /.gitignore,才不覆盖生成默认的
const hasRootGitignore = assets && assets['/.gitignore'];
if (!hasGitignore) {
zip.file(`${rootDir}/.gitignore`, DEFAULT_GITIGNORE_CONTENT);
if (!hasRootGitignore) {
zip.file(`.gitignore`, DEFAULT_GITIGNORE_CONTENT);
}
// Add assets (optional)
if (assets) {
Object.entries(assets).forEach(([path, content]) => {
// 以 / 开头的代表强制放在压缩包根目录;否则照常放进资源包(技能)目录下
if (path.startsWith('/')) {
addFileToZip(zip, path.slice(1), content);
} else {
addFileToZip(zip, `${rootDir}/${path}`, content);
});
}
// Add additional files (optional)
if (additionalFiles) {
Object.entries(additionalFiles).forEach(([path, content]) => {
addFileToZip(zip, `${rootDir}/${path}`, content);
});
}
......@@ -140,7 +133,10 @@ async function generateZipBuffer(zip: JSZip): Promise<Buffer> {
*
* 兼容两种形态:历史单 skill 包(一个 SKILL.md)和多 skill 包(多个一级目录各自含 SKILL.md)。
*/
export async function validateZipStructure(zipBuffer: Buffer): Promise<ZipValidationResult> {
export async function validateZipStructure(
zipBuffer: Buffer,
options: { maxUncompressedBytes?: number } = {}
): Promise<ZipValidationResult> {
try {
const zip = await JSZip.loadAsync(zipBuffer);
const files = Object.keys(zip.files);
......@@ -154,6 +150,44 @@ export async function validateZipStructure(zipBuffer: Buffer): Promise<ZipValida
};
}
let totalUncompressedBytes = 0;
for (const file of Object.values(zip.files)) {
const unsafePath = file.unsafeOriginalName ?? file.name;
if (!isSafeZipEntryPath(unsafePath)) {
return {
valid: false,
hasSkillMd: false,
files,
error: `Unsafe ZIP entry path: ${unsafePath}`
};
}
if (isZipSymlink(file)) {
return {
valid: false,
hasSkillMd: false,
files,
error: `ZIP symlink entries are not allowed: ${unsafePath}`
};
}
if (!file.dir) {
totalUncompressedBytes += getZipEntryUncompressedSize(file);
if (
options.maxUncompressedBytes !== undefined &&
totalUncompressedBytes > options.maxUncompressedBytes
) {
return {
valid: false,
hasSkillMd: false,
files,
totalUncompressedBytes,
error: 'ZIP archive uncompressed size exceeds maximum allowed size'
};
}
}
}
// 兼容根目录直接放 SKILL.md 的历史包。
let skillMdPath = files.find((f) => f.toUpperCase() === 'SKILL.MD');
......@@ -184,7 +218,8 @@ export async function validateZipStructure(zipBuffer: Buffer): Promise<ZipValida
valid: true,
hasSkillMd: true,
files,
skillMdPath
skillMdPath,
totalUncompressedBytes
};
} catch (error) {
return {
......@@ -196,6 +231,37 @@ export async function validateZipStructure(zipBuffer: Buffer): Promise<ZipValida
}
}
function isSafeZipEntryPath(path: string): boolean {
if (!path || path.includes('\0')) return false;
if (path.startsWith('/') || path.startsWith('\\')) return false;
if (/^[A-Za-z]:[\\/]/.test(path)) return false;
return !path.split(/[\\/]+/).some((segment) => segment === '..');
}
function isZipSymlink(file: JSZip.JSZipObject): boolean {
const permissions =
typeof file.unixPermissions === 'string'
? Number.parseInt(file.unixPermissions, 8)
: file.unixPermissions;
return Number.isFinite(permissions) && ((permissions as number) & 0xf000) === 0xa000;
}
function getZipEntryUncompressedSize(file: JSZip.JSZipObject): number {
const compressedData = (
file as JSZip.JSZipObject & {
_data?: {
uncompressedSize?: number;
};
}
)._data;
return Number.isFinite(compressedData?.uncompressedSize)
? Number(compressedData?.uncompressedSize)
: 0;
}
/**
* 从 ZIP Buffer 中提取单 skill 包内容。
*/
......@@ -314,18 +380,6 @@ export async function standardizeSkillPackageBySkillMdName(
}
/**
* 获取 ZIP 内文件列表,读取失败时返回空数组供调试接口容错展示。
*/
export async function getZipFileList(zipBuffer: Buffer): Promise<string[]> {
try {
const zip = await JSZip.loadAsync(zipBuffer);
return Object.keys(zip.files).filter((path) => !zip.files[path].dir);
} catch {
return [];
}
}
/**
* 读取 ZIP 内指定文件,找不到或解析失败时返回 null。
*/
export async function readFileFromZip(zipBuffer: Buffer, filePath: string): Promise<Buffer | null> {
......
......@@ -7,22 +7,15 @@ import {
shellQuote,
joinSandboxPath,
getSkillsRootPath,
getSafeSkillDirectoryName,
getSkillTargetPath
} from '../utils';
import { getLogger, LogCategories } from '../../../../common/logger';
import type { DeployedSkillInfo } from './types';
import { serviceEnv } from '../../../../env';
export type { DeployedSkillInfo } from './types';
const logger = getLogger(LogCategories.MODULE.AI.AGENT);
const trimSandboxPathRight = (value: string) => (value === '/' ? '' : value.replace(/\/+$/, ''));
const getSandboxParentPath = (path: string) => {
const normalizedPath = path.replace(/\/+$/, '');
const slashIndex = normalizedPath.lastIndexOf('/');
return slashIndex > 0 ? normalizedPath.slice(0, slashIndex) : '/';
};
const parseCommandOutputLines = (stdout: string) => stdout.trim().split('\n').filter(Boolean);
type GetAgentSkillInfosParams = {
......@@ -189,6 +182,7 @@ export const injectAgentSkillFilesToSandbox = async ({
throw new Error(`Failed to create skill directories inside sandbox: ${mkdirResult.stderr}`);
}
const maxPackageBytes = serviceEnv.AGENT_SANDBOX_SKILL_MAX_SIZE * 1024 * 1024;
const results = await Promise.all(
deployableSkills.map(async ({ skill, version, targetDir }) => {
try {
......@@ -197,6 +191,8 @@ export const injectAgentSkillFilesToSandbox = async ({
const quotedTargetDir = shellQuote(targetDir);
const unzipCommand = `(${[
`cd ${quotedTargetDir}`,
`unzip -Z -t package.zip | awk -v max=${maxPackageBytes} 'BEGIN { ok=0 } /uncompressed,/ { ok=(($3 + 0) <= max) } END { exit ok ? 0 : 1 }'`,
`unzip -Z1 package.zip | awk 'BEGIN { ok=1 } /^\\// || /(^|\\/)\\.\\.($|\\/)/ { ok=0 } END { exit ok ? 0 : 1 }'`,
`unzip -o -q package.zip`,
`rm -f package.zip`
].join(' && ')})`;
......
/**
* Skill Sandbox Configuration
*
* Provides configuration and defaults for sandbox management.
*/
import { serviceEnv } from '../../../../env';
export { EDIT_DEBUG_SANDBOX_CHAT_ID, getEditDebugSandboxId } from '../edit/config';
export type SkillSizeLimits = {
maxUploadBytes: number; // Compressed upload size limit
maxUncompressedBytes: number; // Uncompressed size after extraction (Zip Bomb guard)
maxDownloadBytes: number; // Download from MinIO/S3
maxSandboxPackageBytes: number; // Sandbox directory size before zip
};
const MB_TO_BYTES = 1024 * 1024;
const mbToBytes = (value: number) => value * MB_TO_BYTES;
/**
* Get skill size limits from the single Skill package size environment variable.
*
* AGENT_SKILL_MAX_UPLOAD_SIZE follows the existing upload-file convention and is configured in MB.
* Runtime checks compare File/Buffer byte lengths, so this function exposes byte values only. The
* derived fields keep call sites explicit about which boundary they are enforcing.
*/
export function getSkillSizeLimits(): SkillSizeLimits {
const maxPackageBytes = mbToBytes(serviceEnv.AGENT_SKILL_MAX_UPLOAD_SIZE);
return {
maxUploadBytes: maxPackageBytes,
maxUncompressedBytes: maxPackageBytes,
maxDownloadBytes: maxPackageBytes,
maxSandboxPackageBytes: maxPackageBytes
};
}
......@@ -144,35 +144,6 @@ function generateFrontmatter(name: string, description: string): string {
}
/**
* 解析由本模板工具生成或兼容的简单 frontmatter。
*
* 这里只服务模板构造相关的轻量读取;导入/部署时的正式 SKILL.md 元数据解析
* 仍应使用 `parseSkillMarkdown`,避免业务校验规则分散。
*/
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 标量的形式。
*/
function escapeYaml(value: string): string {
......@@ -200,41 +171,16 @@ function escapeYaml(value: string): string {
return `"${escaped}"`;
}
/**
* 反解析 `escapeYaml` 支持的简单 quoted scalar。
*/
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 约束。
*/
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);
}
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';
const { frontmatter } = parseSkillMarkdown(content);
if (frontmatter.name) {
return getSafeSkillDirectoryName(String(frontmatter.name)).toLowerCase();
}
} catch {}
const headerMatch = content.match(/^#\s+(.+)$/m);
return headerMatch ? getSafeSkillDirectoryName(headerMatch[1]).toLowerCase() : 'unnamed-skill';
}
/* ==================== Shell 安全辅助 (原 shell.ts) ==================== */
......@@ -338,7 +284,7 @@ export function parseGitignoreRules(gitignoreContents: string[]): GitignoreParse
new Set(
pruneDirs
.map((p) => p.replace(/\/\*$/, '').replace(/^\*\//, ''))
.filter((p) => p && !p.includes('*') && !p.includes('.'))
.filter((p) => p && !p.includes('*'))
)
);
......
......@@ -112,12 +112,12 @@ export async function useSandbox({
);
const getSkillsInfo = async () => {
// 编辑模式下复用编辑器已经写入 skills 目录的 skill 文件,避免工作区其他 SKILL.md 进入 prompt
// 编辑调试包已解压到当前工作目录,调度侧需要扫描同一目录才能读取 SKILL.md
if (hasEditSkill) {
const runtimeProfile = getSandboxRuntimeProfile();
return getAgentSkillInfos({
sandbox: sandboxClient.provider,
workDirectory: runtimeProfile.skillsRootPath
workDirectory: runtimeProfile.workDirectory
});
} else if (hasAgentSkills) {
return injectAgentSkillFilesToSandbox({
......
......@@ -3,6 +3,7 @@ import z from 'zod';
import { isPhaseProductionBuild } from '@fastgpt/global/common/system/constants';
import { DEFAULT_MAX_FOLDER_DEPTH } from '@fastgpt/global/common/parentFolder/depth';
import { BoolSchema, IntSchema, NumSchema, UrlSchema } from '@fastgpt/global/common/zod';
import { hasAgentSandboxConfig as hasAgentSandboxConfigFromEnv } from '@fastgpt/global/core/ai/sandbox/env';
const defaultableIntSchema = (defaultValue: number) =>
z.preprocess(
......@@ -13,25 +14,8 @@ const defaultableIntSchema = (defaultValue: number) =>
// 系统最大字符串处理长度
const SYSTEM_STRING_LENGTH_UNIT = 1_000_000;
/**
* 判断系统是否显式配置了 Agent 虚拟机能力。
* 注意 serviceEnv 会给部分字段填默认值,这里必须读取原始 env,避免把未配置误判为已配置。
*/
export const hasAgentSandboxConfig = () => {
const provider = process.env.AGENT_SANDBOX_PROVIDER;
if (provider === 'sealosdevbox') {
return !!(process.env.AGENT_SANDBOX_SEALOS_BASEURL && process.env.AGENT_SANDBOX_SEALOS_TOKEN);
}
if (provider === 'opensandbox') {
return !!(
process.env.AGENT_SANDBOX_OPENSANDBOX_BASEURL && process.env.AGENT_SANDBOX_OPENSANDBOX_API_KEY
);
}
return false;
};
const optionalNonEmptyString = () =>
z.preprocess((value) => (value === '' ? undefined : value), z.string().optional());
// 枚举
const LogLevelSchema = z.enum(['trace', 'debug', 'info', 'warning', 'error', 'fatal']);
......@@ -75,13 +59,16 @@ export const serviceEnv = createEnv({
PRO_URL: UrlSchema.optional(),
// Agent sandbox
AGENT_SANDBOX_PROVIDER: z.enum(['sealosdevbox', 'opensandbox', 'e2b']).default('opensandbox'),
AGENT_SANDBOX_PROVIDER: z.enum(['sealosdevbox', 'opensandbox', 'e2b']).optional(),
AGENT_SANDBOX_PROXY_SECRET: optionalNonEmptyString(),
IDE_AGENT_BIND_ADDR: z.string().default('0.0.0.0:1318'),
// E2B配置
AGENT_SANDBOX_E2B_API_KEY: z.string().optional(),
// Sealos配置
AGENT_SANDBOX_SEALOS_BASEURL: UrlSchema.optional(),
AGENT_SANDBOX_SEALOS_TOKEN: z.string().optional(),
AGENT_SANDBOX_SEALOS_WORK_DIRECTORY: z.string().default('/home/devbox/workspace'),
AGENT_SANDBOX_SEALOS_IMAGE: z.string().optional(),
// OpenSandbox配置
AGENT_SANDBOX_OPENSANDBOX_BASEURL: UrlSchema.optional(),
AGENT_SANDBOX_OPENSANDBOX_API_KEY: z.string().optional(),
......@@ -92,13 +79,16 @@ export const serviceEnv = createEnv({
AGENT_SANDBOX_ENABLE_VOLUME: BoolSchema.default(false),
AGENT_SANDBOX_VOLUME_MANAGER_URL: UrlSchema.default('http://localhost:3005'),
AGENT_SANDBOX_VOLUME_MANAGER_TOKEN: z.string().optional(),
// Skill 配置
AGENT_SKILL_MAX_UPLOAD_SIZE: NumSchema.default(50).meta({
description: 'Skill 包大小上限(MB),用于上传、解压、下载和 sandbox 打包校验'
AGENT_SANDBOX_ARCHIVE_MAX_SIZE: NumSchema.default(50).meta({
description: 'Agent sandbox 冷归档包大小上限(MB),用于归档包的上传、下载和打包校验'
}),
AGENT_SANDBOX_SKILL_MAX_SIZE: NumSchema.default(10).meta({
description: 'Skill sandbox 包大小上限(MB),用于 Skill 包上传、下载和打包发布校验'
}),
AGENT_SANDBOX_MAX_FILE_SIZE: NumSchema.default(10).meta({
description: 'Agent sandbox IDE 单文件读写和上传大小上限(MB)'
}),
AGENT_SANDBOX_MAX_EDIT_DEBUG: NumSchema.default(100),
AGENT_SANDBOX_MAX_SESSION_RUNTIME: NumSchema.default(300),
// ==================== 数据库与缓存 ====================
// Redisg
......@@ -270,11 +260,6 @@ export const serviceEnv = createEnv({
EVAL_CONCURRENCY: IntSchema.default(3).meta({
description: '评估任务 worker 并发数'
}),
SANDBOX_PROXY_REPLACE_DOCKER_INTERNAL_WITH_LOCALHOST: BoolSchema.default(false).meta({
description:
'是否把 endpoint 中的 host.docker.internal 改写为 localhost;当 sandbox-proxy 直接运行在宿主机进程时开启,容器或 k8s 内运行时保持关闭'
}),
// ==================== 资源限制 ====================
SERVICE_REQUEST_MAX_CONTENT_LENGTH: IntSchema.default(10).meta({
description: '服务器接收请求的最大大小(MB)'
......@@ -326,3 +311,19 @@ if (serviceEnv.WORKFLOW_PARALLEL_MAX_CONCURRENCY > serviceEnv.WORKFLOW_MAX_LOOP_
export const SYSTEM_MAX_STRING_LENGTH =
serviceEnv.SYSTEM_MAX_STRING_LENGTH_M * SYSTEM_STRING_LENGTH_UNIT;
if (hasAgentSandboxConfigFromEnv(process.env)) {
if (!serviceEnv.AGENT_SANDBOX_PROXY_SECRET) {
throw new Error('AGENT_SANDBOX_PROXY_SECRET is required when Agent Sandbox is enabled.');
}
if (serviceEnv.AGENT_SANDBOX_PROXY_SECRET.length < 32) {
throw new Error('AGENT_SANDBOX_PROXY_SECRET must be at least 32 characters.');
}
}
/**
* 判断系统是否显式配置了 Agent 虚拟机能力。
* 必须直读 process.env,避免空环境被 schema 默认值误判为已启用。
*/
export const hasAgentSandboxConfig = (): boolean => hasAgentSandboxConfigFromEnv(process.env);
......@@ -3,6 +3,7 @@ import { MongoSandboxInstance } from '@fastgpt/service/core/ai/sandbox/instance/
import {
buildSandboxInstanceLookup,
countRunningSandboxInstancesByType,
createSandboxResourcesToArchiveCursor,
deleteSandboxInstanceRecord,
deleteSandboxResourceRecord,
findInactiveRunningSandboxResources,
......@@ -16,14 +17,38 @@ import {
findSandboxResourcesByAppId,
findSandboxResourcesByChatIds,
findSkillRelatedSandboxResources,
isSandboxStillArchiving,
markSandboxArchived,
markSandboxArchiving,
migrateArchivedSandboxInstanceRecord,
markSandboxRestored,
markSandboxRestoring,
markSandboxResourceStopped,
updateSandboxInstanceRecordBySandboxId,
upsertRunningSandboxInstance
upsertRunningSandboxInstance,
type SandboxResourceDoc
} from '@fastgpt/service/core/ai/sandbox/instance/repository';
import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { SandboxTypeEnum } from '@fastgpt/global/core/ai/skill/constants';
import { getNanoid } from '@fastgpt/global/common/string/tools';
const collectArchiveCursor = async (
params: Parameters<typeof createSandboxResourcesToArchiveCursor>[0]
) => {
const cursor = createSandboxResourcesToArchiveCursor(params);
const resources: SandboxResourceDoc[] = [];
try {
for await (const resource of cursor) {
resources.push(resource);
}
} finally {
await cursor.close();
}
return resources;
};
describe('sandbox instance helpers', () => {
beforeEach(async () => {
await MongoSandboxInstance.deleteMany({ sandboxId: /^instance-helper-/ });
......@@ -78,15 +103,6 @@ describe('sandbox instance helpers', () => {
expect(staleRecords.map((item) => String(item._id))).toEqual([String(oldProviderDoc._id)]);
});
it('builds lookup by sandbox id and object id', () => {
const objectId = String(new MongoSandboxInstance()._id);
expect(buildSandboxInstanceLookup('plain-id')).toEqual({ $or: [{ sandboxId: 'plain-id' }] });
expect(buildSandboxInstanceLookup(objectId)).toEqual({
$or: [{ sandboxId: objectId }, { _id: objectId }]
});
});
it('upserts running instance and supports common repository queries', async () => {
const appId = `instance-helper-${getNanoid()}`;
const chatId = `chat-${getNanoid()}`;
......@@ -230,6 +246,209 @@ describe('sandbox instance helpers', () => {
await expect(MongoSandboxInstance.exists({ _id: inactiveDoc._id })).resolves.toBeNull();
});
it('migrates archived records to the current provider without deleting archive metadata', async () => {
const appId = `instance-helper-${getNanoid()}`;
const sandboxId = `instance-helper-${getNanoid()}`;
const doc = await MongoSandboxInstance.create({
provider: 'opensandbox',
sandboxId,
appId,
userId: 'user-1',
chatId: 'record-only-chat',
type: SandboxTypeEnum.editDebug,
status: SandboxStatusEnum.stopped,
lastActiveAt: new Date(),
createdAt: new Date(),
metadata: {
archive: {
state: 'archived'
}
}
});
const migratedDoc = await migrateArchivedSandboxInstanceRecord({
source: {
provider: 'opensandbox',
sandboxId,
_id: doc._id
},
provider: 'sealosdevbox',
appId,
userId: '',
chatId: 'record-only-chat',
type: SandboxTypeEnum.editDebug
});
expect(migratedDoc).toMatchObject({
provider: 'sealosdevbox',
sandboxId,
appId,
userId: '',
chatId: 'record-only-chat',
status: SandboxStatusEnum.stopped,
metadata: {
archive: {
state: 'archived'
}
}
});
const migratedExists = await MongoSandboxInstance.exists({ _id: doc._id });
expect(String(migratedExists?._id)).toBe(String(doc._id));
await expect(MongoSandboxInstance.countDocuments({ sandboxId })).resolves.toBe(1);
});
it('moves archived metadata into an existing current-provider placeholder record', async () => {
const appId = `instance-helper-${getNanoid()}`;
const chatId = `placeholder-chat-${getNanoid()}`;
const sandboxId = `instance-helper-${getNanoid()}`;
const oldDoc = await MongoSandboxInstance.create({
provider: 'opensandbox',
sandboxId,
appId,
userId: 'user-1',
chatId,
type: SandboxTypeEnum.editDebug,
status: SandboxStatusEnum.stopped,
lastActiveAt: new Date(),
createdAt: new Date(),
metadata: {
archive: {
state: 'archived'
},
skillId: appId
}
});
const placeholderDoc = await MongoSandboxInstance.create({
provider: 'sealosdevbox',
sandboxId,
appId: `placeholder-${appId}`,
userId: '',
chatId: `placeholder-${chatId}`,
status: SandboxStatusEnum.running,
lastActiveAt: new Date(),
createdAt: new Date(),
metadata: {
volumeEnabled: false
}
});
const migratedDoc = await migrateArchivedSandboxInstanceRecord({
source: {
provider: 'opensandbox',
sandboxId,
_id: oldDoc._id
},
provider: 'sealosdevbox',
appId,
userId: '',
chatId,
type: SandboxTypeEnum.editDebug
});
expect(String(migratedDoc?._id)).toBe(String(placeholderDoc._id));
expect(migratedDoc).toMatchObject({
provider: 'sealosdevbox',
sandboxId,
appId,
userId: '',
chatId,
type: SandboxTypeEnum.editDebug,
status: SandboxStatusEnum.stopped,
metadata: {
archive: {
state: 'archived'
},
skillId: appId
}
});
await expect(MongoSandboxInstance.exists({ _id: oldDoc._id })).resolves.toBeNull();
});
it('archives inactive stopped records and restores them into the current provider', async () => {
const inactiveBefore = new Date('2026-02-01T00:00:00.000Z');
const sandboxId = `instance-helper-${getNanoid()}`;
const doc = await MongoSandboxInstance.create({
provider: 'opensandbox',
sandboxId,
appId: `instance-helper-${getNanoid()}`,
userId: 'user-1',
chatId: 'archive-chat',
type: SandboxTypeEnum.sessionRuntime,
status: SandboxStatusEnum.stopped,
lastActiveAt: new Date('2026-01-01T00:00:00.000Z'),
createdAt: new Date(),
metadata: {
image: { repository: 'image' }
}
});
await expect(collectArchiveCursor({ inactiveBefore })).resolves.toEqual([
expect.objectContaining({ sandboxId })
]);
const archiving = await markSandboxArchiving(doc, inactiveBefore);
expect(archiving).toMatchObject({
sandboxId,
metadata: {
archive: {
state: 'archiving'
}
}
});
await expect(isSandboxStillArchiving(archiving!, inactiveBefore)).resolves.toBe(true);
await expect(
upsertRunningSandboxInstance({
provider: doc.provider,
sandboxId
})
).resolves.toBeNull();
await markSandboxArchived(doc);
await expect(MongoSandboxInstance.findOne({ sandboxId }).lean()).resolves.toMatchObject({
status: SandboxStatusEnum.stopped,
metadata: {
archive: {
state: 'archived'
}
}
});
await expect(collectArchiveCursor({ inactiveBefore })).resolves.not.toContainEqual(
expect.objectContaining({ sandboxId })
);
const restoringDoc = await markSandboxRestoring(doc);
expect(restoringDoc).toMatchObject({
metadata: {
archive: {
state: 'restoring'
}
}
});
const restoredDoc = await markSandboxRestored(doc, {
appId: doc.appId,
userId: 'user-1',
chatId: 'restore-provider-chat',
metadata: {
volumeEnabled: false
}
});
expect(restoredDoc).toMatchObject({
provider: 'opensandbox',
status: SandboxStatusEnum.running,
metadata: {
volumeEnabled: false
}
});
const stored = await MongoSandboxInstance.findOne({ sandboxId }).lean();
expect(stored?.provider).toBe('opensandbox');
expect(stored?.metadata?.archive).toBeUndefined();
expect(stored?.metadata?.provider).toBeUndefined();
expect(stored?.storage).toBeUndefined();
});
it('supports repository optional provider and update branches', async () => {
const appId = `instance-helper-${getNanoid()}`;
const chatId = `chat-${getNanoid()}`;
......
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
createSandbox: vi.fn((provider: string, connectionConfig: unknown, createConfig?: unknown) => ({
provider,
connectionConfig,
createConfig
}))
}));
vi.mock('@fastgpt/service/env', () => ({
serviceEnv: {
AGENT_SANDBOX_PROVIDER: 'opensandbox',
......@@ -11,19 +19,10 @@ vi.mock('@fastgpt/service/env', () => ({
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: 'test',
AGENT_SANDBOX_SEALOS_BASEURL: 'http://mock-sealos.local',
AGENT_SANDBOX_SEALOS_TOKEN: 'mock-sealos-token',
AGENT_SANDBOX_E2B_API_KEY: 'mock-e2b-token',
SANDBOX_PROXY_REPLACE_DOCKER_INTERNAL_WITH_LOCALHOST: false
AGENT_SANDBOX_E2B_API_KEY: 'mock-e2b-token'
}
}));
const mocks = vi.hoisted(() => ({
createSandbox: vi.fn((provider: string, connectionConfig: unknown, createConfig?: unknown) => ({
provider,
connectionConfig,
createConfig
}))
}));
vi.mock('@fastgpt-sdk/sandbox-adapter', () => ({
OPEN_SANDBOX_DEFAULT_ROOT_PATH: '/workspace',
createSandbox: mocks.createSandbox
......
......@@ -5,12 +5,15 @@ const originalEnv = {
AGENT_SANDBOX_SEALOS_BASEURL: process.env.AGENT_SANDBOX_SEALOS_BASEURL,
AGENT_SANDBOX_SEALOS_TOKEN: process.env.AGENT_SANDBOX_SEALOS_TOKEN,
AGENT_SANDBOX_SEALOS_WORK_DIRECTORY: process.env.AGENT_SANDBOX_SEALOS_WORK_DIRECTORY,
AGENT_SANDBOX_SEALOS_IMAGE: process.env.AGENT_SANDBOX_SEALOS_IMAGE,
AGENT_SANDBOX_E2B_API_KEY: process.env.AGENT_SANDBOX_E2B_API_KEY,
AGENT_SANDBOX_OPENSANDBOX_BASEURL: process.env.AGENT_SANDBOX_OPENSANDBOX_BASEURL,
AGENT_SANDBOX_OPENSANDBOX_API_KEY: process.env.AGENT_SANDBOX_OPENSANDBOX_API_KEY,
AGENT_SANDBOX_OPENSANDBOX_RUNTIME: process.env.AGENT_SANDBOX_OPENSANDBOX_RUNTIME,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO,
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG
AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG: process.env.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG,
AGENT_SANDBOX_MAX_FILE_SIZE: process.env.AGENT_SANDBOX_MAX_FILE_SIZE,
AGENT_SANDBOX_PROXY_SECRET: process.env.AGENT_SANDBOX_PROXY_SECRET
};
const loadSandboxConfigModule = async () => {
......@@ -35,6 +38,7 @@ const defaultOpenSandboxDockerNetworkPolicy = {
describe('sandbox provider config', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv('AGENT_SANDBOX_PROXY_SECRET', 'test-secret-123456789012345678901234');
});
afterEach(() => {
......@@ -45,6 +49,7 @@ describe('sandbox provider config', () => {
'AGENT_SANDBOX_SEALOS_WORK_DIRECTORY',
originalEnv.AGENT_SANDBOX_SEALOS_WORK_DIRECTORY
);
vi.stubEnv('AGENT_SANDBOX_SEALOS_IMAGE', originalEnv.AGENT_SANDBOX_SEALOS_IMAGE);
vi.stubEnv('AGENT_SANDBOX_E2B_API_KEY', originalEnv.AGENT_SANDBOX_E2B_API_KEY);
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', originalEnv.AGENT_SANDBOX_OPENSANDBOX_BASEURL);
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', originalEnv.AGENT_SANDBOX_OPENSANDBOX_API_KEY);
......@@ -57,6 +62,8 @@ describe('sandbox provider config', () => {
'AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG',
originalEnv.AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG
);
vi.stubEnv('AGENT_SANDBOX_MAX_FILE_SIZE', originalEnv.AGENT_SANDBOX_MAX_FILE_SIZE);
vi.stubEnv('AGENT_SANDBOX_PROXY_SECRET', originalEnv.AGENT_SANDBOX_PROXY_SECRET);
vi.unstubAllGlobals();
});
......@@ -86,6 +93,20 @@ describe('sandbox provider config', () => {
});
});
it('does not default sandbox provider when env is empty', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', undefined);
const { getConfiguredSandboxProvider, getSandboxProviderConfig } =
await loadSandboxConfigModule();
expect(getConfiguredSandboxProvider).toThrow(
'AGENT_SANDBOX_PROVIDER is required when Agent Sandbox is used'
);
expect(getSandboxProviderConfig).toThrow(
'AGENT_SANDBOX_PROVIDER is required when Agent Sandbox is used'
);
});
it('keeps e2b runtime create config when runtime adapter config is requested', async () => {
vi.stubEnv('AGENT_SANDBOX_E2B_API_KEY', 'e2b-token');
......@@ -117,23 +138,62 @@ describe('sandbox provider config', () => {
const { getSandboxAdapterConfig } = await loadSandboxConfigModule();
expect(
getSandboxAdapterConfig({
// 1. 无环境变量 Image 时,传空 repository 让 Sealos 走默认 agent 镜像
const result = getSandboxAdapterConfig({
provider: 'sealosdevbox',
runtime: true,
sessionId: 'session-1'
})
).toEqual({
providerConfig: {
});
expect(result.providerConfig).toEqual({
provider: 'sealosdevbox',
baseUrl: 'https://devbox.example.com',
token: 'sealos-token'
});
expect(result.createConfig).toEqual({
image: {
repository: ''
},
createConfig: {
workingDir: '/home/devbox/workspace',
upstreamID: 'session-1'
upstreamID: 'session-1',
env: {
FASTGPT_SESSION_ID: 'session-1',
FASTGPT_WORKDIR: '/home/devbox/workspace',
IDE_AGENT_ENABLED: 'true',
IDE_AGENT_BIND_ADDR: '0.0.0.0:1318',
FASTGPT_IDE_MAX_FILE_BYTES: '10485760'
}
});
// 2. 有环境变量 Image 时,携带 image 字段
vi.stubEnv('AGENT_SANDBOX_SEALOS_IMAGE', 'default-sealos-image:latest');
vi.resetModules();
const { getSandboxAdapterConfig: getSandboxAdapterConfigWithImage } =
await loadSandboxConfigModule();
const resultWithEnvImage = getSandboxAdapterConfigWithImage({
provider: 'sealosdevbox',
runtime: true,
sessionId: 'session-1'
});
expect(resultWithEnvImage.createConfig?.image).toEqual({
repository: 'default-sealos-image',
tag: 'latest'
});
// 3. 显式传入镜像时,覆盖环境变量中的默认镜像
const resultWithExplicitImage = getSandboxAdapterConfigWithImage({
provider: 'sealosdevbox',
runtime: true,
sessionId: 'session-1',
createConfig: {
image: { repository: 'explicit-sealos-image', tag: 'v1' }
}
});
expect(resultWithExplicitImage.createConfig?.image).toEqual({
repository: 'explicit-sealos-image',
tag: 'v1'
});
});
it('normalizes missing provider env values before validation', async () => {
......@@ -166,6 +226,30 @@ describe('sandbox provider config', () => {
}
});
it('allows empty proxy secret before agent sandbox credentials are configured', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', 'opensandbox');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', 'http://opensandbox.local');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', '');
vi.stubEnv('AGENT_SANDBOX_PROXY_SECRET', '');
vi.resetModules();
const { serviceEnv } = await import('@fastgpt/service/env');
expect(serviceEnv.AGENT_SANDBOX_PROXY_SECRET).toBeUndefined();
});
it('rejects short proxy secret when agent sandbox is configured', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', 'opensandbox');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', 'http://opensandbox.local');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_API_KEY', 'opensandbox-api-key');
vi.stubEnv('AGENT_SANDBOX_PROXY_SECRET', 'short');
vi.resetModules();
await expect(import('@fastgpt/service/env')).rejects.toThrow(
'AGENT_SANDBOX_PROXY_SECRET must be at least 32 characters'
);
});
it('parses opensandbox config and runtime create config from env', async () => {
vi.stubEnv('AGENT_SANDBOX_PROVIDER', 'opensandbox');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_BASEURL', 'http://opensandbox.local');
......@@ -234,16 +318,28 @@ describe('sandbox provider config', () => {
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_RUNTIME', 'docker');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_REPO', 'default-opensandbox-image');
vi.stubEnv('AGENT_SANDBOX_OPENSANDBOX_IMAGE_TAG', 'stable');
vi.resetModules();
const { getSandboxRuntimeProfile } =
await import('@fastgpt/service/core/ai/sandbox/runtime/profile');
const profile = getSandboxRuntimeProfile('opensandbox');
expect(profile.buildConfig()).toEqual({
image: {
repository: 'default-opensandbox-image',
tag: 'stable'
},
networkPolicy: defaultOpenSandboxDockerNetworkPolicy
});
expect(getSandboxRuntimeProfile('opensandbox').buildConfig()).toEqual({
expect(
profile.buildConfig({
entrypoint: profile.entrypoint
})
).toEqual({
image: {
repository: 'default-opensandbox-image',
tag: 'stable'
},
entrypoint: ['/home/sandbox/entrypoint.sh'],
networkPolicy: defaultOpenSandboxDockerNetworkPolicy
});
});
......@@ -302,19 +398,6 @@ describe('sandbox provider config', () => {
).toThrow('Invalid runtime: invalid');
});
it('requires opensandbox api key for docker runtime', async () => {
const { validateSandboxConfig } = await loadSandboxConfigModule();
expect(() =>
validateSandboxConfig({
provider: 'opensandbox',
baseUrl: 'http://opensandbox.local',
apiKey: '',
runtime: 'docker'
})
).toThrow('Sandbox provider apiKey is required for opensandbox');
});
it('throws for unsupported provider in config switch', async () => {
const { getSandboxAdapterConfig } = await loadSandboxConfigModule();
......
......@@ -234,7 +234,7 @@ describe('sandbox provider lifecycle', () => {
const connectPromise = connectToSandbox(sealosConfig, 'sandbox-retry-timeout');
const assertion = expect(connectPromise).rejects.toThrow('outer retryable failure');
await vi.advanceTimersByTimeAsync(120_000);
await vi.advanceTimersByTimeAsync(300_000);
await assertion;
expect(sandbox.execute).toHaveBeenCalled();
......@@ -335,15 +335,14 @@ describe('sandbox provider lifecycle', () => {
});
mocks.buildSandboxAdapter.mockReturnValueOnce(sandbox);
await expect(
connectReadySandboxByInstance(sealosConfig, { sandboxId: 'stable-session-id' })
).resolves.toMatchObject({
sandboxInfo: {
const connected = await connectReadySandboxByInstance(sealosConfig, {
sandboxId: 'stable-session-id'
});
expect(connected.sandboxInfo).toMatchObject({
id: 'provider-sandbox-id',
status: { state: 'Running' },
image: { repository: '' }
}
status: { state: 'Running' }
});
expect(connected.sandboxInfo).not.toHaveProperty('image');
expect(sandbox.ensureRunning).toHaveBeenCalledTimes(1);
expect(sandbox.waitUntilReady).toHaveBeenCalledTimes(1);
......
......@@ -57,9 +57,6 @@ describe('sandbox runtime profile', () => {
expect(runtimeProfile).toMatchObject({
provider: 'sealosdevbox',
defaultImage: {
repository: ''
},
workDirectory: '/home/devbox/workspace',
entrypoint: ''
});
......@@ -74,9 +71,6 @@ describe('sandbox runtime profile', () => {
expect(getSandboxRuntimeProfile()).toMatchObject({
provider: 'sealosdevbox',
defaultImage: {
repository: ''
},
workDirectory: '/custom/devbox/workspace',
entrypoint: ''
});
......@@ -93,13 +87,21 @@ describe('sandbox runtime profile', () => {
runtimeProfile.buildConfig({
scenario: 'session-runtime',
sessionId: 'session-1',
env: buildBaseSandboxRuntimeEnv('session-1', runtimeProfile.workDirectory),
env: buildBaseSandboxRuntimeEnv({
sessionId: 'session-1',
workDirectory: runtimeProfile.workDirectory,
ideAgentBindAddr: '0.0.0.0:1318',
ideAgentMaxFileBytes: 10 * 1024 * 1024
}),
metadata: { teamId: 'team-1' }
})
).toEqual({
).toMatchObject({
env: {
FASTGPT_SESSION_ID: 'session-1',
FASTGPT_WORKDIR: '/custom/devbox/workspace'
FASTGPT_WORKDIR: '/custom/devbox/workspace',
IDE_AGENT_ENABLED: 'true',
IDE_AGENT_BIND_ADDR: '0.0.0.0:1318',
FASTGPT_IDE_MAX_FILE_BYTES: '10485760'
},
metadata: {
teamId: 'team-1'
......
......@@ -14,7 +14,13 @@ import { delay } from '@fastgpt/global/common/system/utils';
const { Types } = connectionMongo;
const hasSandboxEnv = !!process.env.AGENT_SANDBOX_PROVIDER;
const hasSandboxEnv =
!!process.env.AGENT_SANDBOX_PROVIDER &&
(process.env.AGENT_SANDBOX_PROVIDER === 'e2b'
? !!process.env.AGENT_SANDBOX_E2B_API_KEY
: process.env.AGENT_SANDBOX_PROVIDER === 'sealosdevbox'
? !!process.env.AGENT_SANDBOX_SEALOS_BASEURL
: !!process.env.AGENT_SANDBOX_OPENSANDBOX_BASEURL);
const runFullIntegration = process.env.SANDBOX_INTEGRATION_FULL === 'true';
vi.mock('@fastgpt/service/env', () => ({
......
......@@ -2,14 +2,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const cronMocks = vi.hoisted(() => ({
setCron: vi.fn(),
checkTimerLock: vi.fn(),
findInactiveRunningSandboxResources: vi.fn(),
stopSandboxResources: vi.fn()
stopSandboxResources: vi.fn(),
archiveInactiveSandboxes: vi.fn()
}));
vi.mock('@fastgpt/service/common/system/cron', () => ({
setCron: cronMocks.setCron
}));
vi.mock('@fastgpt/service/common/system/timerLock/utils', () => ({
checkTimerLock: cronMocks.checkTimerLock
}));
vi.mock('@fastgpt/service/common/system/timerLock/constants', () => ({
TimerIdEnum: {
archiveInactiveSandboxes: 'archiveInactiveSandboxes'
}
}));
vi.mock('@fastgpt/service/core/ai/sandbox/instance/repository', () => ({
findInactiveRunningSandboxResources: cronMocks.findInactiveRunningSandboxResources
}));
......@@ -18,11 +30,17 @@ vi.mock('@fastgpt/service/core/ai/sandbox/service/resource', () => ({
stopSandboxResources: cronMocks.stopSandboxResources
}));
vi.mock('@fastgpt/service/core/ai/sandbox/service/archive', () => ({
archiveInactiveSandboxes: cronMocks.archiveInactiveSandboxes
}));
import { cronJob } from '@fastgpt/service/core/ai/sandbox/service/cron';
describe('sandbox cron service', () => {
beforeEach(() => {
vi.clearAllMocks();
cronMocks.checkTimerLock.mockResolvedValue(true);
cronMocks.archiveInactiveSandboxes.mockResolvedValue(undefined);
});
it('registers a cron task and skips when no inactive sandbox exists', async () => {
......@@ -47,4 +65,17 @@ describe('sandbox cron service', () => {
expect(cronMocks.stopSandboxResources).toHaveBeenCalledWith(resources);
});
it('runs archive cron under timer lock', async () => {
await cronJob();
const callback = cronMocks.setCron.mock.calls[1]?.[1];
await callback();
expect(cronMocks.setCron).toHaveBeenCalledWith('0 */12 * * *', expect.any(Function));
expect(cronMocks.checkTimerLock).toHaveBeenCalledWith({
timerId: 'archiveInactiveSandboxes',
lockMinuted: 660
});
expect(cronMocks.archiveInactiveSandboxes).toHaveBeenCalledTimes(1);
});
});
......@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
},
buildSandboxResourceAdapter: vi.fn(),
deleteSessionVolume: vi.fn(),
deleteWorkspaceArchive: vi.fn(),
deleteSandboxResourceRecord: vi.fn(),
findSandboxResourcesByAppId: vi.fn(),
findSandboxResourcesByChatIds: vi.fn(),
......@@ -33,6 +34,12 @@ vi.mock('@fastgpt/service/core/ai/sandbox/volume/service', () => ({
deleteSessionVolume: mocks.deleteSessionVolume
}));
vi.mock('@fastgpt/service/common/s3/sources/sandbox', () => ({
getS3SandboxSource: () => ({
deleteWorkspaceArchive: mocks.deleteWorkspaceArchive
})
}));
vi.mock('@fastgpt/service/core/ai/sandbox/instance/repository', () => ({
deleteSandboxResourceRecord: mocks.deleteSandboxResourceRecord,
findSandboxResourcesByAppId: mocks.findSandboxResourcesByAppId,
......@@ -65,6 +72,8 @@ describe('sandbox resource service', () => {
delete: vi.fn(async () => undefined)
});
mocks.deleteSessionVolume.mockResolvedValue(undefined);
mocks.deleteWorkspaceArchive.mockResolvedValue(undefined);
mocks.deleteSandboxResourceRecord.mockResolvedValue(undefined);
mocks.findSandboxResourcesByAppId.mockResolvedValue([]);
mocks.findSandboxResourcesByChatIds.mockResolvedValue([]);
......@@ -81,6 +90,29 @@ describe('sandbox resource service', () => {
expect(mocks.markSandboxResourceStopped).toHaveBeenCalledWith(resource);
});
it('does not mark stopped when stale cron stop loses the record CAS', async () => {
const resource = {
...createResource(),
lastActiveAt: new Date('2026-01-01T00:00:00.000Z')
};
const adapter = {
stop: vi.fn(async () => undefined),
delete: vi.fn(async () => undefined)
};
mocks.buildSandboxResourceAdapter.mockReturnValueOnce(adapter);
mocks.markSandboxResourceStopped.mockResolvedValueOnce({ matchedCount: 0 });
await stopSandboxResource(resource);
expect(adapter.stop).toHaveBeenCalledTimes(1);
expect(mocks.logger.warn).toHaveBeenCalledWith(
'Skip marking sandbox stopped because record changed after stop',
expect.objectContaining({
sandboxId: resource.sandboxId
})
);
});
it('deletes a resource and keeps deleting the record when volume cleanup fails', async () => {
const resource = createResource();
mocks.deleteSessionVolume.mockRejectedValueOnce(new Error('volume cleanup failed'));
......@@ -90,6 +122,9 @@ describe('sandbox resource service', () => {
const adapter = mocks.buildSandboxResourceAdapter.mock.results[0].value;
expect(adapter.delete).toHaveBeenCalledTimes(1);
expect(mocks.deleteSessionVolume).toHaveBeenCalledWith('sandbox-1');
expect(mocks.deleteWorkspaceArchive).toHaveBeenCalledWith({
sandboxId: 'sandbox-1'
});
expect(mocks.deleteSandboxResourceRecord).toHaveBeenCalledWith(resource);
expect(mocks.logger.error).toHaveBeenCalledWith(
'Failed to delete sandbox volume',
......@@ -99,6 +134,29 @@ describe('sandbox resource service', () => {
);
});
it('deletes a resource and skips deleting the volume when keepVolume is true', async () => {
const resource = createResource();
await deleteSandboxResource(resource, { keepVolume: true });
const adapter = mocks.buildSandboxResourceAdapter.mock.results[0].value;
expect(adapter.delete).toHaveBeenCalledTimes(1);
expect(mocks.deleteSessionVolume).not.toHaveBeenCalled();
expect(mocks.deleteSandboxResourceRecord).toHaveBeenCalledWith(resource);
});
it('skips FastGPT volume deletion for Sealos resources', async () => {
const resource = {
...createResource(),
provider: 'sealosdevbox'
};
await deleteSandboxResource(resource);
expect(mocks.deleteSessionVolume).not.toHaveBeenCalled();
expect(mocks.deleteSandboxResourceRecord).toHaveBeenCalledWith(resource);
});
it('returns early when chat or app cleanup finds no resources', async () => {
await deleteSandboxesByChatIds({ appId: 'app-1', chatIds: ['chat-1'] });
await deleteSandboxesByAppId('app-1');
......@@ -126,26 +184,6 @@ describe('sandbox resource service', () => {
);
});
it('logs delete failures while processing app resources', async () => {
const resource = createResource();
mocks.findSandboxResourcesByAppId.mockResolvedValueOnce([resource]);
mocks.buildSandboxResourceAdapter.mockReturnValueOnce({
stop: vi.fn(async () => undefined),
delete: vi.fn(async () => {
throw new Error('delete failed');
})
});
await deleteSandboxesByAppId('app-1');
expect(mocks.logger.error).toHaveBeenCalledWith(
'Failed to delete sandbox',
expect.objectContaining({
sandboxId: 'sandbox-1'
})
);
});
it('logs stop failures while processing inactive resources', async () => {
const resource = createResource();
mocks.buildSandboxResourceAdapter.mockReturnValueOnce({
......
......@@ -19,6 +19,8 @@ const mocks = vi.hoisted(() => ({
stopSandboxResource: vi.fn(),
getSessionVolumeConfig: vi.fn(),
upsertRunningSandboxInstance: vi.fn(),
assertSandboxNotArchivedOrBusy: vi.fn(),
restoreArchivedSandboxBeforeUse: vi.fn(),
findSandboxAppIdBySandboxId: vi.fn(),
mongoAppFindById: vi.fn(),
mongoAgentSkillsFindById: vi.fn(),
......@@ -58,6 +60,21 @@ vi.mock('@fastgpt/service/core/ai/sandbox/instance/repository', () => ({
upsertRunningSandboxInstance: mocks.upsertRunningSandboxInstance
}));
vi.mock('@fastgpt/service/core/ai/sandbox/service/archive', () => {
class SandboxArchiveStateError extends Error {
constructor(readonly state: string) {
super(`Sandbox is ${state}`);
this.name = 'SandboxArchiveStateError';
}
}
return {
SandboxArchiveStateError,
assertSandboxNotArchivedOrBusy: mocks.assertSandboxNotArchivedOrBusy,
restoreArchivedSandboxBeforeUse: mocks.restoreArchivedSandboxBeforeUse
};
});
vi.mock('@fastgpt/service/core/app/schema', () => ({
MongoApp: {
findById: mocks.mongoAppFindById
......@@ -88,9 +105,12 @@ const createProvider = () =>
describe('sandbox runtime service', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
mocks.getSessionVolumeConfig.mockResolvedValue(undefined);
mocks.upsertRunningSandboxInstance.mockResolvedValue(undefined);
mocks.upsertRunningSandboxInstance.mockResolvedValue({ sandboxId: 'sandbox-doc' });
mocks.assertSandboxNotArchivedOrBusy.mockResolvedValue(undefined);
mocks.restoreArchivedSandboxBeforeUse.mockResolvedValue(undefined);
mocks.ensureConnectedSandboxRunning.mockResolvedValue(undefined);
mocks.deleteSandboxResource.mockResolvedValue(undefined);
mocks.stopSandboxResource.mockResolvedValue(undefined);
......@@ -105,7 +125,7 @@ describe('sandbox runtime service', () => {
const client = await getSandboxClient({ sandboxId: 'sandbox-ready-check' });
expect(client.getSandboxId()).toBe('sandbox-ready-check');
expect(mocks.getSessionVolumeConfig).toHaveBeenCalledWith('sandbox-ready-check');
expect(mocks.getSessionVolumeConfig).not.toHaveBeenCalled();
expect(mocks.buildRuntimeSandboxAdapter).toHaveBeenCalledWith(
'sealosdevbox',
'sandbox-ready-check',
......@@ -125,6 +145,61 @@ describe('sandbox runtime service', () => {
expect(mocks.ensureConnectedSandboxRunning).toHaveBeenCalledTimes(1);
});
it('prepares FastGPT volume only for OpenSandbox runtime', async () => {
const vmConfig = {
volumes: [{ name: 'workspace', pvc: { claimName: 'claim-1' }, mountPath: '/workspace' }],
storage: { mountPath: '/workspace' }
};
mocks.getSessionVolumeConfig.mockResolvedValue(vmConfig);
await getSandboxClient(
{ sandboxId: 'opensandbox-volume' },
{
providerName: 'opensandbox'
}
);
expect(mocks.getSessionVolumeConfig).toHaveBeenCalledWith('opensandbox-volume');
expect(mocks.getSessionVolumeConfig).toHaveBeenCalledTimes(1);
expect(mocks.restoreArchivedSandboxBeforeUse).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'opensandbox',
sandboxId: 'opensandbox-volume',
vmConfig,
storage: { mountPath: '/workspace' }
})
);
expect(mocks.assertSandboxNotArchivedOrBusy).not.toHaveBeenCalledWith({
provider: 'opensandbox',
sandboxId: 'opensandbox-volume'
});
expect(mocks.upsertRunningSandboxInstance).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'opensandbox',
sandboxId: 'opensandbox-volume',
storage: { mountPath: '/workspace' }
})
);
});
it('blocks archived sandbox when restore is disabled', async () => {
mocks.assertSandboxNotArchivedOrBusy.mockRejectedValueOnce(new Error('Sandbox is archived'));
await expect(
getSandboxClient({ sandboxId: 'archived-sandbox' }, { restoreArchived: false })
).rejects.toThrow('Sandbox is archived');
expect(mocks.upsertRunningSandboxInstance).not.toHaveBeenCalled();
});
it('blocks archiving sandbox access before runtime can recreate it', async () => {
mocks.restoreArchivedSandboxBeforeUse.mockRejectedValueOnce(new Error('Sandbox is archiving'));
await expect(getSandboxClient({ sandboxId: 'archiving-sandbox' })).rejects.toThrow(
'Sandbox is archiving'
);
expect(mocks.upsertRunningSandboxInstance).not.toHaveBeenCalled();
});
it('builds sandbox id from app/user/chat triplet and omits user for edit-debug chat', async () => {
const client = await getSandboxClient({
appId: 'app-1',
......@@ -143,6 +218,20 @@ describe('sandbox runtime service', () => {
});
expect(client.getSandboxId()).toBe(generateSandboxId('app-1', 'user-1', 'normal-chat'));
expect(mocks.buildRuntimeSandboxAdapter).toHaveBeenCalledWith(
'sealosdevbox',
client.getSandboxId(),
expect.not.objectContaining({
createConfig: expect.anything()
})
);
});
it('rejects incomplete app/chat query instead of deriving a shared empty sandbox id', async () => {
await expect(getSandboxClient({ appId: 'app-1' } as any)).rejects.toThrow(
'appId and chatId are required'
);
expect(mocks.buildRuntimeSandboxAdapter).not.toHaveBeenCalled();
});
it('passes resource limits into running instance records and command timeout into exec', async () => {
......
import { afterEach, describe, expect, it } from 'vitest';
import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants';
import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema';
import { getSkillCreationLLMModel } from '@fastgpt/service/core/ai/skill/manage/creation/model';
import { getSkillCreationLLMModel } from '@fastgpt/service/core/ai/model';
const originalSystemDefaultModel = global.systemDefaultModel;
......@@ -26,7 +26,7 @@ describe('skill creation model selection', () => {
global.systemDefaultModel = originalSystemDefaultModel;
});
it('uses the helper bot model resolved from HELPER_BOT_MODEL', () => {
it('uses the system default LLM even when helper bot model is configured', () => {
const systemModel = buildLlmModel('system-default-model', true);
const helperModel = buildLlmModel('helper-env-model');
......@@ -36,7 +36,7 @@ describe('skill creation model selection', () => {
helperBotLLM: helperModel
};
expect(getSkillCreationLLMModel()).toBe('helper-env-model');
expect(getSkillCreationLLMModel()).toBe('system-default-model');
});
it('falls back to the system default LLM when helper bot model is missing', () => {
......
import { describe, it, expect, vi } from 'vitest';
import JSZip from 'jszip';
import {
getAgentSkillInfos,
injectAgentSkillFilesToSandbox
......@@ -15,7 +16,7 @@ import {
} from '@fastgpt/global/core/ai/sandbox/tools';
import { MongoAgentSkills } from '@fastgpt/service/core/ai/skill/model/schema';
import { MongoAgentSkillsVersion } from '@fastgpt/service/core/ai/skill/version/schema';
import { uploadSkillPackage, JSZip } from '@fastgpt/service/core/ai/skill/package';
import { uploadSkillPackage } from '@fastgpt/service/core/ai/skill/package';
import { AgentSkillSourceEnum } from '@fastgpt/global/core/ai/skill/constants';
import { Types } from '@fastgpt/service/common/mongo';
......@@ -231,6 +232,9 @@ description: Zeta skill
expect(unzipCommands).toHaveLength(1);
expect(unzipCommands[0]).toContain(`cd '${skill1TargetDir}'`);
expect(unzipCommands[0]).toContain(`cd '${skill2TargetDir}'`);
expect(unzipCommands[0]).not.toContain('unzip -tq package.zip >/dev/null');
expect(unzipCommands[0]).toContain('unzip -Z -t package.zip');
expect(unzipCommands[0]).toContain('unzip -Z1 package.zip');
expect(unzipCommands[0]).toContain('unzip -o -q package.zip');
const findSkillCommands = sandbox.execute.mock.calls
......
......@@ -6,7 +6,6 @@ import {
} from '@fastgpt/service/core/ai/skill/package';
import { getS3SkillSource } from '@fastgpt/service/common/s3/sources/skill';
import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill';
import { getSkillSizeLimits } from '@fastgpt/service/core/ai/skill/sandbox/config';
import { serviceEnv } from '@fastgpt/service/env';
const s3SkillSourceMocks = vi.hoisted(() => {
......@@ -66,25 +65,6 @@ describe('storage', () => {
vi.clearAllMocks();
});
describe('getSkillSizeLimits', () => {
it('should derive all skill size limits from the upload env value in MB', () => {
const originalMaxUploadSize = serviceEnv.AGENT_SKILL_MAX_UPLOAD_SIZE;
serviceEnv.AGENT_SKILL_MAX_UPLOAD_SIZE = 1;
try {
expect(getSkillSizeLimits()).toEqual({
maxUploadBytes: 1 * 1024 * 1024,
maxUncompressedBytes: 1 * 1024 * 1024,
maxDownloadBytes: 1 * 1024 * 1024,
maxSandboxPackageBytes: 1 * 1024 * 1024
});
} finally {
serviceEnv.AGENT_SKILL_MAX_UPLOAD_SIZE = originalMaxUploadSize;
}
});
});
// ==================== uploadSkillPackage ====================
describe('uploadSkillPackage', () => {
it('should upload skill package successfully', async () => {
......@@ -106,53 +86,12 @@ describe('storage', () => {
});
});
it('should use S3SkillSource for upload', async () => {
await uploadSkillPackage({
teamId: mockTeamId,
skillId: mockSkillId,
packageObjectId: mockVersionId,
zipBuffer: mockZipBuffer
});
expect(getS3SkillSource).toHaveBeenCalled();
});
it('should generate correct key for different version objects', async () => {
const versions = [0, 1, 5, 10];
for (const version of versions) {
const versionId = `version-object-${version}`;
const result = await uploadSkillPackage({
teamId: mockTeamId,
skillId: mockSkillId,
packageObjectId: versionId,
zipBuffer: mockZipBuffer
});
expect(result.key).toBe(`agent-skills/${mockTeamId}/${mockSkillId}/${versionId}.zip`);
}
});
it('should handle large zip buffers', async () => {
const largeBuffer = Buffer.alloc(10 * 1024 * 1024); // 10MB
const result = await uploadSkillPackage({
teamId: mockTeamId,
skillId: mockSkillId,
packageObjectId: mockVersionId,
zipBuffer: largeBuffer
});
expect(result.key).toBe(`agent-skills/${mockTeamId}/${mockSkillId}/${mockVersionId}.zip`);
});
it('should reject zip buffers larger than the upload limit before uploading to S3', async () => {
const originalMaxUploadSize = serviceEnv.AGENT_SKILL_MAX_UPLOAD_SIZE;
serviceEnv.AGENT_SKILL_MAX_UPLOAD_SIZE = 1;
const originalSkillSandboxMaxSize = serviceEnv.AGENT_SANDBOX_SKILL_MAX_SIZE;
serviceEnv.AGENT_SANDBOX_SKILL_MAX_SIZE = 1;
try {
const { maxUploadBytes } = getSkillSizeLimits();
const tooLargeBuffer = Buffer.alloc(maxUploadBytes + 1);
const tooLargeBuffer = Buffer.alloc(1024 * 1024 + 1);
await expect(
uploadSkillPackage({
......@@ -166,7 +105,7 @@ describe('storage', () => {
expect(getS3SkillSource).not.toHaveBeenCalled();
expect(s3SkillSourceMocks.uploadPackageMock).not.toHaveBeenCalled();
} finally {
serviceEnv.AGENT_SKILL_MAX_UPLOAD_SIZE = originalMaxUploadSize;
serviceEnv.AGENT_SANDBOX_SKILL_MAX_SIZE = originalSkillSandboxMaxSize;
}
});
});
......
import { describe, expect, it } from 'vitest';
import JSZip from 'jszip';
import {
createSkillPackage,
validateZipStructure,
extractSkillPackage,
standardizeSkillPackageBySkillMdName,
JSZip
standardizeSkillPackageBySkillMdName
} from '@fastgpt/service/core/ai/skill/package';
describe('zipBuilder', () => {
......@@ -160,6 +160,18 @@ ${largeMarkdown}`;
expect(result.hasSkillMd).toBe(false);
expect(result.error).toContain('SKILL.md');
});
it('should reject unsafe zip entry paths', async () => {
const zip = new JSZip();
zip.file('SKILL.md', '---\nname: test\n---');
zip.file('../escape.txt', 'escape');
const buffer = await zip.generateAsync({ type: 'nodebuffer' });
const result = await validateZipStructure(buffer);
expect(result.valid).toBe(false);
expect(result.error).toContain('Unsafe ZIP entry path');
});
});
// ==================== extractSkillPackage ====================
......
......@@ -210,7 +210,7 @@ const createProps = () =>
}
}) as any;
const getEditSkillsRootPath = () => getSandboxRuntimeProfile().skillsRootPath;
const getEditWorkDirectory = () => getSandboxRuntimeProfile().workDirectory;
describe('dispatchRunAgent user context', () => {
beforeEach(() => {
......@@ -502,7 +502,7 @@ describe('dispatchRunAgent user context', () => {
});
expect(getAgentSkillInfosMock).toHaveBeenCalledWith({
sandbox: expect.any(Object),
workDirectory: getEditSkillsRootPath()
workDirectory: getEditWorkDirectory()
});
expect(injectAgentSkillFilesToSandboxMock).not.toHaveBeenCalled();
......
......@@ -134,7 +134,7 @@ vi.mock('@fastgpt/service/core/dataset/utils', async (importOriginal) => {
};
});
const getEditSkillsRootPath = () => getSandboxRuntimeProfile().skillsRootPath;
const getEditWorkDirectory = () => getSandboxRuntimeProfile().workDirectory;
const createProps = () =>
({
......@@ -599,7 +599,7 @@ describe('dispatchPiAgent user context', () => {
});
expect(getAgentSkillInfosMock).toHaveBeenCalledWith({
sandbox: expect.any(Object),
workDirectory: getEditSkillsRootPath()
workDirectory: getEditWorkDirectory()
});
expect(injectAgentSkillFilesToSandboxMock).not.toHaveBeenCalled();
......
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