diff --git a/.github/workflows/sandbox-test.yaml b/.github/workflows/sandbox-test.yaml new file mode 100644 index 0000000000..668fc7f63e --- /dev/null +++ b/.github/workflows/sandbox-test.yaml @@ -0,0 +1,32 @@ +name: 'Sandbox-Test' +on: + pull_request: + paths: + - 'projects/sandbox/**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: projects/sandbox + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref || github.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install Deps + run: bun install + + - name: Run Unit Tests + run: bun run test diff --git a/document/app/global.css b/document/app/global.css index 727943768f..75f60b8c58 100644 --- a/document/app/global.css +++ b/document/app/global.css @@ -476,7 +476,7 @@ button[role='tab'] { padding-bottom: 16px; } -/* 移动端侧边栏 padding 缩小 */ +/* 移动端侧边栏左侧 padding 缩小 */ @media (max-width: 767px) { #nd-sidebar-mobile [data-radix-scroll-area-viewport] { padding-left: 8px; @@ -498,8 +498,12 @@ button[role='tab'] { } /* RootToggle 图标容器:缩小到与图标匹配,修复对齐和间距 */ +#nd-sidebar .size-9, +#nd-sidebar .size-5, #nd-sidebar-mobile .size-9, -[data-radix-popper-content-wrapper] .size-9 { +#nd-sidebar-mobile .size-5, +[data-radix-popper-content-wrapper] .size-9, +[data-radix-popper-content-wrapper] .size-5 { width: 20px !important; height: 20px !important; flex-shrink: 0; @@ -517,6 +521,9 @@ button[role='tab'] { padding-left: 12px !important; padding-right: 12px !important; border-radius: 8px; + display: flex !important; + align-items: center !important; + gap: 8px; } /* 移动端目录下拉框(TOC Popover Trigger):文本溢出省略号 */ @@ -536,4 +543,12 @@ button[role='tab'] { overflow-wrap: break-word; word-break: break-word; } + + /* RootToggle 图标容器:缩小尺寸,对齐文字 */ + #nd-sidebar-mobile [data-radix-popper-anchor] .size-9, + #nd-sidebar-mobile button .size-9 { + width: 20px !important; + height: 20px !important; + flex-shrink: 0; + } } diff --git a/document/content/docs/introduction/guide/dashboard/workflow/meta.en.json b/document/content/docs/introduction/guide/dashboard/workflow/meta.en.json index 14a59b13cf..11e3aff5ff 100644 --- a/document/content/docs/introduction/guide/dashboard/workflow/meta.en.json +++ b/document/content/docs/introduction/guide/dashboard/workflow/meta.en.json @@ -15,6 +15,7 @@ "http", "tfswitch", "variable_update", + "sandbox-v2", "sandbox", "loop", "knowledge_base_search_merge", diff --git a/document/content/docs/introduction/guide/dashboard/workflow/meta.json b/document/content/docs/introduction/guide/dashboard/workflow/meta.json index 87dafff300..0734d3742f 100644 --- a/document/content/docs/introduction/guide/dashboard/workflow/meta.json +++ b/document/content/docs/introduction/guide/dashboard/workflow/meta.json @@ -1,5 +1,26 @@ { "title": "工作流节点", "description": "FastGPT 工作流节点设置和使用指南", - "pages": ["ai_chat","dataset_search","tool","question_classify","content_extract","user-selection","form_input","text_editor","reply","document_parsing","http","tfswitch","variable_update","sandbox","loop","knowledge_base_search_merge","coreferenceResolution","laf","custom_feedback"] -} \ No newline at end of file + "pages": [ + "ai_chat", + "dataset_search", + "tool", + "question_classify", + "content_extract", + "user-selection", + "form_input", + "text_editor", + "reply", + "document_parsing", + "http", + "tfswitch", + "variable_update", + "sandbox-v2", + "sandbox", + "loop", + "knowledge_base_search_merge", + "coreferenceResolution", + "laf", + "custom_feedback" + ] +} diff --git a/document/content/docs/introduction/guide/dashboard/workflow/sandbox-v2.en.mdx b/document/content/docs/introduction/guide/dashboard/workflow/sandbox-v2.en.mdx new file mode 100644 index 0000000000..2b62e3be5f --- /dev/null +++ b/document/content/docs/introduction/guide/dashboard/workflow/sandbox-v2.en.mdx @@ -0,0 +1,391 @@ +--- +title: Code Run +description: FastGPT Code Run node documentation (for version 4.14.8 and above) +--- + +> This document applies to FastGPT **version 4.14.8 and above**. For version 4.14.7 and earlier, see [Code Run (Deprecated)](/docs/introduction/guide/dashboard/workflow/sandbox). + +## Features + +The Code Run node executes JavaScript and Python code in a secure sandbox for data processing, format conversion, logic calculations, and similar tasks. + +**Supported Languages** + +- JavaScript (Bun runtime) +- Python 3 + +**Important Notes** + +- Self-hosted users need to deploy the `fastgpt-sandbox` image and configure the `SANDBOX_URL` environment variable. +- The sandbox has a default maximum runtime of 60s (configurable). +- Code runs in isolated process pools with no access to the file system or internal network. + +## Variable Input + +Add variables needed for code execution in custom inputs. + +**JavaScript** — Destructure in the main function parameters: + +```js +async function main({data1, data2}){ + return { + result: data1 + data2 + } +} +``` + +**Python** — Receive variables by name in the main function parameters: + +```python +def main(data1, data2): + return {"result": data1 + data2} +``` + +## Result Output + +Always return an object (JS) or dict (Python). + +In custom outputs, add variable names to access values by their keys. For example, if you return: + +```json +{ + "result": "hello", + "count": 42 +} +``` + +Add `result` and `count` variables in custom outputs to retrieve their values. + +## Built-in Functions + +### httpRequest - Make HTTP Requests + +Make external HTTP requests from within the sandbox. Internal network addresses are automatically blocked (SSRF protection). + +**JavaScript Example:** + +```js +async function main({url}){ + const res = await SystemHelper.httpRequest(url, { + method: 'GET', // Request method, default GET + headers: {}, // Custom request headers + body: null, // Request body (objects are auto JSON-serialized) + timeout: 60 // Timeout in seconds, max 60s + }) + return { + status: res.status, + data: res.data + } +} +``` + +**Python Example:** + +```python +def main(url): + res = SystemHelper.httpRequest(url, method="GET", headers={}, timeout=10) + return {"status": res["status"], "data": res["data"]} +``` + +**Limitations:** +- Maximum 30 requests per execution +- Single request timeout: 60s +- Maximum response body: 2MB +- Only http/https protocols allowed +- Internal IPs automatically blocked (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, etc.) + +## Available Modules + +### JavaScript Whitelist + +The following npm modules are available via `require()`: + +| Module | Description | Example | +|--------|-------------|---------| +| `lodash` | Utility library | `const _ = require('lodash')` | +| `moment` | Date handling | `const moment = require('moment')` | +| `dayjs` | Lightweight date library | `const dayjs = require('dayjs')` | +| `crypto-js` | Encryption library | `const CryptoJS = require('crypto-js')` | +| `uuid` | UUID generation | `const { v4 } = require('uuid')` | +| `qs` | Query string parsing | `const qs = require('qs')` | + +Other modules (such as `fs`, `child_process`, `net`, etc.) are prohibited. + +### Python Whitelist + +The following Python standard library and third-party modules can be imported directly: + +**Math and Numerical Computing** + +| Module | Description | +|--------|-------------| +| `math` | Mathematical functions | +| `cmath` | Complex number math | +| `decimal` | Decimal floating-point arithmetic | +| `fractions` | Fraction arithmetic | +| `random` | Random number generation | +| `statistics` | Statistical functions | + +**Data Structures and Algorithms** + +| Module | Description | +|--------|-------------| +| `collections` | Container data types | +| `array` | Arrays | +| `heapq` | Heap queue | +| `bisect` | Array bisection | +| `queue` | Queues | +| `copy` | Shallow and deep copy | + +**Functional Programming** + +| Module | Description | +|--------|-------------| +| `itertools` | Iterator tools | +| `functools` | Higher-order functions | +| `operator` | Standard operators | + +**String and Text Processing** + +| Module | Description | +|--------|-------------| +| `string` | String constants | +| `re` | Regular expressions | +| `difflib` | Diff calculation | +| `textwrap` | Text wrapping | +| `unicodedata` | Unicode database | +| `codecs` | Codec registry | + +**Date and Time** + +| Module | Description | +|--------|-------------| +| `datetime` | Date and time | +| `time` | Time access | +| `calendar` | Calendar | + +**Data Serialization** + +| Module | Description | +|--------|-------------| +| `json` | JSON encoding/decoding | +| `csv` | CSV file handling | +| `base64` | Base64 encoding/decoding | +| `binascii` | Binary-to-ASCII conversion | +| `struct` | Byte string parsing | + +**Encryption and Hashing** + +| Module | Description | +|--------|-------------| +| `hashlib` | Hash algorithms | +| `hmac` | HMAC message authentication | +| `secrets` | Secure random numbers | +| `uuid` | UUID generation | + +**Types and Abstractions** + +| Module | Description | +|--------|-------------| +| `typing` | Type hints | +| `abc` | Abstract base classes | +| `enum` | Enumeration types | +| `dataclasses` | Data classes | +| `contextlib` | Context managers | + +**Other Utilities** + +| Module | Description | +|--------|-------------| +| `pprint` | Pretty printing | +| `weakref` | Weak references | + +**Third-party Libraries** + +| Module | Description | +|--------|-------------| +| `numpy` | Numerical computing | +| `pandas` | Data analysis | +| `matplotlib` | Data visualization | + +**Prohibited modules:** `os`, `sys`, `subprocess`, `socket`, `urllib`, `http`, `requests`, and any modules involving system calls, network access, or file system operations. + +## Security Restrictions + +The sandbox provides multiple layers of security protection: + +- **Module Restrictions:** Only whitelisted modules are allowed for both JS and Python +- **Network Isolation:** Internal IP requests are automatically blocked (SSRF protection) +- **File Isolation:** No read/write access to the container file system +- **Timeout Protection:** Default 60s timeout prevents infinite loops +- **Process Isolation:** Each execution runs in an independent sandbox process + +## Usage Examples + +### JavaScript Examples + +
+Data Format Conversion + +```js +// Convert comma-separated string to array +function main({input}){ + const items = input.split(',').map(s => s.trim()).filter(Boolean) + return { items, count: items.length } +} +``` + +
+ +
+Date Calculation + +```js +const dayjs = require('dayjs') + +function main(){ + const now = dayjs() + return { + today: now.format('YYYY-MM-DD'), + nextWeek: now.add(7, 'day').format('YYYY-MM-DD'), + timestamp: now.valueOf() + } +} +``` + +
+ +
+HTTP Request - Get Weather + +```js +async function main({city}){ + const res = await SystemHelper.httpRequest( + `https://api.example.com/weather?city=${city}`, + { method: 'GET', timeout: 10 } + ) + + return { + temperature: res.data.temp, + weather: res.data.condition + } +} +``` + +
+ +
+Data Encryption + +```js +const CryptoJS = require('crypto-js') + +function main({text, key}){ + const encrypted = CryptoJS.AES.encrypt(text, key).toString() + return { encrypted } +} +``` + +
+ +### Python Examples + +
+Data Statistics + +```python +import math + +def main(numbers): + if not numbers: + return {"error": "no data"} + + mean = sum(numbers) / len(numbers) + variance = sum((x - mean)**2 for x in numbers) / len(numbers) + + return { + "mean": mean, + "max": max(numbers), + "min": min(numbers), + "std": math.sqrt(variance) + } +``` + +
+ +
+Date Processing + +```python +from datetime import datetime, timedelta + +def main(date_str): + dt = datetime.strptime(date_str, "%Y-%m-%d") + next_week = dt + timedelta(days=7) + + return { + "input": date_str, + "next_week": next_week.strftime("%Y-%m-%d"), + "weekday": dt.strftime("%A") + } +``` + +
+ +
+HTTP Request - API Call + +```python +def main(api_url, api_key): + res = SystemHelper.httpRequest( + api_url, + method="GET", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=10 + ) + + return { + "status": res["status"], + "data": res["data"] + } +``` + +
+ +
+JSON Data Processing + +```python +import json + +def main(json_str): + data = json.loads(json_str) + + # Extract specific fields + result = { + "names": [item["name"] for item in data if "name" in item], + "count": len(data) + } + + return result +``` + +
+ +
+Regular Expression Matching + +```python +import re + +def main(text): + # Extract all email addresses + emails = re.findall(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', text) + + return { + "emails": emails, + "count": len(emails) + } +``` + +
diff --git a/document/content/docs/introduction/guide/dashboard/workflow/sandbox-v2.mdx b/document/content/docs/introduction/guide/dashboard/workflow/sandbox-v2.mdx new file mode 100644 index 0000000000..51bd414161 --- /dev/null +++ b/document/content/docs/introduction/guide/dashboard/workflow/sandbox-v2.mdx @@ -0,0 +1,391 @@ +--- +title: 代码运行 +description: FastGPT 代码运行节点介绍(适用于 4.14.8 及以上版本) +--- + +> 本文档适用于 FastGPT **4.14.8 及以上版本**。4.14.7 及以下版本请参考 [代码运行(弃)](/docs/introduction/guide/dashboard/workflow/sandbox)。 + +## 功能 + +代码运行节点支持在安全沙盒中执行 JavaScript 和 Python 代码,用于数据处理、格式转换、逻辑计算等场景。 + +**支持的语言** + +- JavaScript(基于 Bun 运行时) +- Python 3 + +**注意事项** + +- 私有化用户需要部署 `fastgpt-sandbox` 镜像,并配置 `SANDBOX_URL` 环境变量。 +- 沙盒默认最大运行 60s,可通过配置调整。 +- 代码运行在隔离的进程池中,无法访问文件系统和内网。 + +## 变量输入 + +可在自定义输入中添加代码运行需要的变量。 + +**JavaScript** — 在 main 函数参数中解构: + +```js +async function main({data1, data2}){ + return { + result: data1 + data2 + } +} +``` + +**Python** — 在 main 函数参数中按变量名接收: + +```python +def main(data1, data2): + return {"result": data1 + data2} +``` + +## 结果输出 + +务必返回一个 object 对象(JS)或 dict 字典(Python)。 + +自定义输出中,可以添加变量名来获取对应 key 下的值。例如返回了: + +```json +{ + "result": "hello", + "count": 42 +} +``` + +自定义输出中添加 `result` 和 `count` 两个变量即可获取对应的值。 + +## 内置函数 + +### httpRequest 发起 HTTP 请求 + +在沙盒内发起外部 HTTP 请求。自动拦截内网地址(SSRF 防护)。 + +**JavaScript 示例:** + +```js +async function main({url}){ + const res = await SystemHelper.httpRequest(url, { + method: 'GET', // 请求方法,默认 GET + headers: {}, // 自定义请求头 + body: null, // 请求体(对象会自动 JSON 序列化) + timeout: 60 // 超时秒数,最大 60s + }) + return { + status: res.status, + data: res.data + } +} +``` + +**Python 示例:** + +```python +def main(url): + res = SystemHelper.httpRequest(url, method="GET", headers={}, timeout=10) + return {"status": res["status"], "data": res["data"]} +``` + +**限制:** +- 每次执行最多 30 个请求 +- 单次请求超时 60s +- 响应体最大 2MB +- 仅允许 http/https 协议 +- 自动拦截内网 IP(127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 等) + +## 可用模块 + +### JavaScript 白名单模块 + +以下 npm 模块可通过 `require()` 使用: + +| 模块 | 说明 | 示例 | +|------|------|------| +| `lodash` | 工具函数库 | `const _ = require('lodash')` | +| `moment` | 日期处理 | `const moment = require('moment')` | +| `dayjs` | 轻量日期库 | `const dayjs = require('dayjs')` | +| `crypto-js` | 加密库 | `const CryptoJS = require('crypto-js')` | +| `uuid` | UUID 生成 | `const { v4 } = require('uuid')` | +| `qs` | 查询字符串解析 | `const qs = require('qs')` | + +其他模块(如 `fs`, `child_process`, `net` 等)被禁止使用。 + +### Python 白名单模块 + +以下 Python 标准库和第三方库可直接 import: + +**数学和数值计算** + +| 模块 | 说明 | +|------|------| +| `math` | 数学函数 | +| `cmath` | 复数数学 | +| `decimal` | 十进制浮点运算 | +| `fractions` | 分数运算 | +| `random` | 随机数生成 | +| `statistics` | 统计函数 | + +**数据结构和算法** + +| 模块 | 说明 | +|------|------| +| `collections` | 容器数据类型 | +| `array` | 数组 | +| `heapq` | 堆队列 | +| `bisect` | 数组二分查找 | +| `queue` | 队列 | +| `copy` | 浅拷贝和深拷贝 | + +**函数式编程** + +| 模块 | 说明 | +|------|------| +| `itertools` | 迭代器工具 | +| `functools` | 高阶函数 | +| `operator` | 标准运算符 | + +**字符串和文本处理** + +| 模块 | 说明 | +|------|------| +| `string` | 字符串常量 | +| `re` | 正则表达式 | +| `difflib` | 差异计算 | +| `textwrap` | 文本包装 | +| `unicodedata` | Unicode 数据库 | +| `codecs` | 编解码器 | + +**日期和时间** + +| 模块 | 说明 | +|------|------| +| `datetime` | 日期时间 | +| `time` | 时间访问 | +| `calendar` | 日历 | + +**数据序列化** + +| 模块 | 说明 | +|------|------| +| `json` | JSON 编解码 | +| `csv` | CSV 文件读写 | +| `base64` | Base64 编解码 | +| `binascii` | 二进制和 ASCII 转换 | +| `struct` | 字节串解析 | + +**加密和哈希** + +| 模块 | 说明 | +|------|------| +| `hashlib` | 哈希算法 | +| `hmac` | HMAC 消息认证 | +| `secrets` | 安全随机数 | +| `uuid` | UUID 生成 | + +**类型和抽象** + +| 模块 | 说明 | +|------|------| +| `typing` | 类型提示 | +| `abc` | 抽象基类 | +| `enum` | 枚举类型 | +| `dataclasses` | 数据类 | +| `contextlib` | 上下文管理器 | + +**其他实用工具** + +| 模块 | 说明 | +|------|------| +| `pprint` | 美化打印 | +| `weakref` | 弱引用 | + +**第三方库** + +| 模块 | 说明 | +|------|------| +| `numpy` | 数值计算 | +| `pandas` | 数据分析 | +| `matplotlib` | 数据可视化 | + +**禁止使用的模块:** `os`, `sys`, `subprocess`, `socket`, `urllib`, `http`, `requests` 等涉及系统调用、网络访问、文件系统的模块。 + +## 安全限制 + +沙盒提供多层安全防护: + +- **模块拦截**:JS 和 Python 均只允许使用白名单模块 +- **网络隔离**:自动拦截内网 IP 请求(SSRF 防护) +- **文件隔离**:无法读写容器文件系统 +- **超时保护**:默认 60s 超时,防止死循环 +- **进程隔离**:每次执行在独立的沙盒进程中运行 + +## 使用示例 + +### JavaScript 示例 + +
+数据格式转换 + +```js +// 将逗号分隔的字符串转为数组 +function main({input}){ + const items = input.split(',').map(s => s.trim()).filter(Boolean) + return { items, count: items.length } +} +``` + +
+ +
+日期计算 + +```js +const dayjs = require('dayjs') + +function main(){ + const now = dayjs() + return { + today: now.format('YYYY-MM-DD'), + nextWeek: now.add(7, 'day').format('YYYY-MM-DD'), + timestamp: now.valueOf() + } +} +``` + +
+ +
+HTTP 请求 - 获取天气 + +```js +async function main({city}){ + const res = await SystemHelper.httpRequest( + `https://api.example.com/weather?city=${city}`, + { method: 'GET', timeout: 10 } + ) + + return { + temperature: res.data.temp, + weather: res.data.condition + } +} +``` + +
+ +
+数据加密 + +```js +const CryptoJS = require('crypto-js') + +function main({text, key}){ + const encrypted = CryptoJS.AES.encrypt(text, key).toString() + return { encrypted } +} +``` + +
+ +### Python 示例 + +
+数据统计 + +```python +import math + +def main(numbers): + if not numbers: + return {"error": "no data"} + + mean = sum(numbers) / len(numbers) + variance = sum((x - mean)**2 for x in numbers) / len(numbers) + + return { + "mean": mean, + "max": max(numbers), + "min": min(numbers), + "std": math.sqrt(variance) + } +``` + +
+ +
+日期处理 + +```python +from datetime import datetime, timedelta + +def main(date_str): + dt = datetime.strptime(date_str, "%Y-%m-%d") + next_week = dt + timedelta(days=7) + + return { + "input": date_str, + "next_week": next_week.strftime("%Y-%m-%d"), + "weekday": dt.strftime("%A") + } +``` + +
+ +
+HTTP 请求 - API 调用 + +```python +def main(api_url, api_key): + res = SystemHelper.httpRequest( + api_url, + method="GET", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=10 + ) + + return { + "status": res["status"], + "data": res["data"] + } +``` + +
+ +
+JSON 数据处理 + +```python +import json + +def main(json_str): + data = json.loads(json_str) + + # 提取特定字段 + result = { + "names": [item["name"] for item in data if "name" in item], + "count": len(data) + } + + return result +``` + +
+ +
+正则表达式匹配 + +```python +import re + +def main(text): + # 提取所有邮箱地址 + emails = re.findall(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', text) + + return { + "emails": emails, + "count": len(emails) + } +``` + +
diff --git a/document/content/docs/introduction/guide/dashboard/workflow/sandbox.mdx b/document/content/docs/introduction/guide/dashboard/workflow/sandbox.mdx index e78a4be3d3..4abc6cc4b8 100644 --- a/document/content/docs/introduction/guide/dashboard/workflow/sandbox.mdx +++ b/document/content/docs/introduction/guide/dashboard/workflow/sandbox.mdx @@ -1,8 +1,10 @@ --- -title: 代码运行 -description: FastGPT 代码运行节点介绍 +title: 代码运行(弃) +description: FastGPT 代码运行节点介绍(适用于 4.14.7 及以下版本) --- +> 本文档适用于 FastGPT **4.14.7 及以下版本**。4.14.8 及以上版本请参考 [代码运行(新版)](/docs/introduction/guide/dashboard/workflow/sandbox-v5)。 + ![alt text](/imgs/image.png) ## 功能 diff --git a/document/content/docs/toc.en.mdx b/document/content/docs/toc.en.mdx index 6a47d12372..3935ed2db6 100644 --- a/document/content/docs/toc.en.mdx +++ b/document/content/docs/toc.en.mdx @@ -67,6 +67,7 @@ description: FastGPT Toc - [/en/docs/introduction/guide/dashboard/workflow/question_classify](/en/docs/introduction/guide/dashboard/workflow/question_classify) - [/en/docs/introduction/guide/dashboard/workflow/reply](/en/docs/introduction/guide/dashboard/workflow/reply) - [/en/docs/introduction/guide/dashboard/workflow/sandbox](/en/docs/introduction/guide/dashboard/workflow/sandbox) +- [/en/docs/introduction/guide/dashboard/workflow/sandbox-v2](/en/docs/introduction/guide/dashboard/workflow/sandbox-v2) - [/en/docs/introduction/guide/dashboard/workflow/text_editor](/en/docs/introduction/guide/dashboard/workflow/text_editor) - [/en/docs/introduction/guide/dashboard/workflow/tfswitch](/en/docs/introduction/guide/dashboard/workflow/tfswitch) - [/en/docs/introduction/guide/dashboard/workflow/tool](/en/docs/introduction/guide/dashboard/workflow/tool) diff --git a/document/content/docs/toc.mdx b/document/content/docs/toc.mdx index 6b7c935c5a..707a4fec0a 100644 --- a/document/content/docs/toc.mdx +++ b/document/content/docs/toc.mdx @@ -67,6 +67,7 @@ description: FastGPT 文档目录 - [/docs/introduction/guide/dashboard/workflow/question_classify](/docs/introduction/guide/dashboard/workflow/question_classify) - [/docs/introduction/guide/dashboard/workflow/reply](/docs/introduction/guide/dashboard/workflow/reply) - [/docs/introduction/guide/dashboard/workflow/sandbox](/docs/introduction/guide/dashboard/workflow/sandbox) +- [/docs/introduction/guide/dashboard/workflow/sandbox-v2](/docs/introduction/guide/dashboard/workflow/sandbox-v2) - [/docs/introduction/guide/dashboard/workflow/text_editor](/docs/introduction/guide/dashboard/workflow/text_editor) - [/docs/introduction/guide/dashboard/workflow/tfswitch](/docs/introduction/guide/dashboard/workflow/tfswitch) - [/docs/introduction/guide/dashboard/workflow/tool](/docs/introduction/guide/dashboard/workflow/tool) diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index a654992a95..e9233a891c 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -1,408 +1,408 @@ { - "document/content/docs/faq/app.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/faq/app.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/faq/app.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/faq/chat.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/faq/chat.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/faq/chat.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/faq/dataset.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/faq/dataset.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/faq/dataset.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/faq/error.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/faq/error.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/faq/error.mdx": "2025-12-10T20:07:05+08:00", - "document/content/docs/faq/external_channel_integration.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/faq/external_channel_integration.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/faq/external_channel_integration.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/faq/index.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/faq/index.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/faq/index.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/faq/other.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/faq/other.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/faq/other.mdx": "2025-08-04T22:07:52+08:00", - "document/content/docs/faq/points_consumption.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/faq/points_consumption.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/faq/points_consumption.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/introduction/cloud.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/introduction/cloud.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/cloud.mdx": "2026-02-26T00:26:52+08:00", - "document/content/docs/introduction/commercial.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/introduction/commercial.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/commercial.mdx": "2025-09-21T23:09:46+08:00", - "document/content/docs/introduction/development/configuration.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/configuration.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/configuration.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/introduction/development/custom-models/bge-rerank.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/custom-models/bge-rerank.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/custom-models/bge-rerank.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/development/custom-models/chatglm2-m3e.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/custom-models/chatglm2-m3e.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/custom-models/chatglm2-m3e.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/introduction/development/custom-models/chatglm2.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/custom-models/chatglm2.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/custom-models/chatglm2.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/development/custom-models/m3e.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/custom-models/m3e.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/custom-models/m3e.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/development/custom-models/marker.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/custom-models/marker.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/custom-models/marker.mdx": "2025-08-04T22:07:52+08:00", - "document/content/docs/introduction/development/custom-models/mineru.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/custom-models/mineru.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/custom-models/mineru.mdx": "2025-09-17T18:33:31+08:00", - "document/content/docs/introduction/development/custom-models/ollama.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/custom-models/ollama.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/custom-models/ollama.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/introduction/development/custom-models/xinference.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/custom-models/xinference.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/custom-models/xinference.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/introduction/development/design/dataset.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/design/dataset.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/design/dataset.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/development/design/design_plugin.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/design/design_plugin.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/design/design_plugin.mdx": "2025-11-06T14:47:55+08:00", - "document/content/docs/introduction/development/docker.en.mdx": "2026-02-26T18:17:53+08:00", + "document/content/docs/introduction/development/docker.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/docker.mdx": "2026-02-13T11:35:13+08:00", - "document/content/docs/introduction/development/faq.en.mdx": "2026-02-26T18:17:53+08:00", + "document/content/docs/introduction/development/faq.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/faq.mdx": "2025-08-12T22:22:18+08:00", - "document/content/docs/introduction/development/intro.en.mdx": "2026-02-26T18:17:53+08:00", + "document/content/docs/introduction/development/intro.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/intro.mdx": "2026-02-12T18:02:02+08:00", - "document/content/docs/introduction/development/migration/docker_db.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/migration/docker_db.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/migration/docker_db.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/development/migration/docker_mongo.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/migration/docker_mongo.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/migration/docker_mongo.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/development/modelConfig/ai-proxy.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/modelConfig/ai-proxy.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/modelConfig/ai-proxy.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/introduction/development/modelConfig/intro.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/modelConfig/intro.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/modelConfig/intro.mdx": "2025-12-03T08:36:19+08:00", - "document/content/docs/introduction/development/modelConfig/one-api.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/modelConfig/one-api.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/modelConfig/one-api.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/development/modelConfig/ppio.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/modelConfig/ppio.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/modelConfig/ppio.mdx": "2025-09-29T11:52:39+08:00", - "document/content/docs/introduction/development/modelConfig/siliconCloud.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/introduction/development/modelConfig/siliconCloud.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/modelConfig/siliconCloud.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/introduction/development/object-storage.en.mdx": "2026-02-26T17:44:00+08:00", + "document/content/docs/introduction/development/object-storage.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/object-storage.mdx": "2026-01-09T18:25:02+08:00", - "document/content/docs/introduction/development/proxy/cloudflare.en.mdx": "2026-02-26T17:44:00+08:00", + "document/content/docs/introduction/development/proxy/cloudflare.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/proxy/cloudflare.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/development/proxy/http_proxy.en.mdx": "2026-02-26T17:44:00+08:00", + "document/content/docs/introduction/development/proxy/http_proxy.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/proxy/http_proxy.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/development/proxy/nginx.en.mdx": "2026-02-26T17:44:00+08:00", + "document/content/docs/introduction/development/proxy/nginx.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/proxy/nginx.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/development/sealos.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/introduction/development/sealos.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/sealos.mdx": "2026-02-26T16:29:03+08:00", - "document/content/docs/introduction/development/signoz.en.mdx": "2026-02-26T17:53:07+08:00", + "document/content/docs/introduction/development/signoz.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/development/signoz.mdx": "2026-02-12T16:37:50+08:00", - "document/content/docs/introduction/guide/DialogBoxes/htmlRendering.en.mdx": "2026-02-26T17:53:07+08:00", + "document/content/docs/introduction/guide/DialogBoxes/htmlRendering.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/DialogBoxes/htmlRendering.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/DialogBoxes/quoteList.en.mdx": "2026-02-26T17:53:07+08:00", + "document/content/docs/introduction/guide/DialogBoxes/quoteList.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/DialogBoxes/quoteList.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/admin/sso.en.mdx": "2026-02-26T17:53:07+08:00", + "document/content/docs/introduction/guide/admin/sso.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/admin/sso.mdx": "2026-01-05T20:53:39+08:00", - "document/content/docs/introduction/guide/admin/teamMode.en.mdx": "2026-02-26T17:53:07+08:00", + "document/content/docs/introduction/guide/admin/teamMode.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/admin/teamMode.mdx": "2025-08-27T16:59:57+08:00", - "document/content/docs/introduction/guide/course/ai_settings.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/course/ai_settings.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/course/ai_settings.mdx": "2025-07-24T13:00:27+08:00", - "document/content/docs/introduction/guide/course/chat_input_guide.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/course/chat_input_guide.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/course/chat_input_guide.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/course/fileInput.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/course/fileInput.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/course/fileInput.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/course/quick-start.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/course/quick-start.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/course/quick-start.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/basic-mode.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/dashboard/basic-mode.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/basic-mode.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/evaluation.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/dashboard/evaluation.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/evaluation.mdx": "2025-07-24T13:10:25+08:00", - "document/content/docs/introduction/guide/dashboard/gapier.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/dashboard/gapier.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/gapier.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/intro.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/dashboard/intro.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/intro.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/mcp_server.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/dashboard/mcp_server.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/mcp_server.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/mcp_tools.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/dashboard/mcp_tools.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/mcp_tools.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/ai_chat.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/ai_chat.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/ai_chat.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/content_extract.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/content_extract.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/content_extract.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/coreferenceResolution.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/coreferenceResolution.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/coreferenceResolution.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/custom_feedback.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/custom_feedback.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/custom_feedback.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/dataset_search.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/dataset_search.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/dataset_search.mdx": "2025-07-24T13:00:27+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/document_parsing.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/document_parsing.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/document_parsing.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/form_input.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/form_input.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/form_input.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/http.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/http.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/http.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/knowledge_base_search_merge.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/knowledge_base_search_merge.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/knowledge_base_search_merge.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/laf.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/laf.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/laf.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/loop.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/loop.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/loop.mdx": "2025-09-17T22:29:56+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/question_classify.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/question_classify.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/question_classify.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/reply.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/reply.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/reply.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/sandbox.en.mdx": "2026-02-26T16:50:22+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/sandbox.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/text_editor.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/sandbox.en.mdx": "2026-02-26T22:14:30+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/sandbox.mdx": "2026-02-26T15:08:45+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/text_editor.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/text_editor.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/tfswitch.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/tfswitch.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/tfswitch.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/tool.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/tool.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/tool.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/user-selection.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/user-selection.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/user-selection.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/dashboard/workflow/variable_update.en.mdx": "2026-02-26T16:42:10+08:00", + "document/content/docs/introduction/guide/dashboard/workflow/variable_update.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/dashboard/workflow/variable_update.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/knowledge_base/RAG.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/knowledge_base/RAG.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/knowledge_base/RAG.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/knowledge_base/api_dataset.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/knowledge_base/api_dataset.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/knowledge_base/api_dataset.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/knowledge_base/collection_tags.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/knowledge_base/collection_tags.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/knowledge_base/collection_tags.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/introduction/guide/knowledge_base/dataset_engine.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/knowledge_base/dataset_engine.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/knowledge_base/dataset_engine.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/knowledge_base/lark_dataset.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/knowledge_base/lark_dataset.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/knowledge_base/lark_dataset.mdx": "2025-09-17T17:40:47+08:00", - "document/content/docs/introduction/guide/knowledge_base/template.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/knowledge_base/template.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/knowledge_base/template.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/knowledge_base/third_dataset.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/knowledge_base/third_dataset.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/knowledge_base/third_dataset.mdx": "2025-07-24T13:00:27+08:00", - "document/content/docs/introduction/guide/knowledge_base/websync.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/knowledge_base/websync.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/knowledge_base/websync.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/introduction/guide/knowledge_base/yuque_dataset.en.mdx": "2026-02-26T16:50:22+08:00", + "document/content/docs/introduction/guide/knowledge_base/yuque_dataset.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/knowledge_base/yuque_dataset.mdx": "2025-09-17T22:29:56+08:00", - "document/content/docs/introduction/guide/plugins/bing_search_plugin.en.mdx": "2026-02-26T17:53:07+08:00", + "document/content/docs/introduction/guide/plugins/bing_search_plugin.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/plugins/bing_search_plugin.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/plugins/dev_system_tool.en.mdx": "2026-02-26T17:53:07+08:00", + "document/content/docs/introduction/guide/plugins/dev_system_tool.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/plugins/dev_system_tool.mdx": "2025-11-06T14:47:55+08:00", - "document/content/docs/introduction/guide/plugins/doc2x_plugin_guide.en.mdx": "2026-02-26T18:17:53+08:00", + "document/content/docs/introduction/guide/plugins/doc2x_plugin_guide.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/plugins/doc2x_plugin_guide.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/plugins/google_search_plugin_guide.en.mdx": "2026-02-26T18:17:53+08:00", + "document/content/docs/introduction/guide/plugins/google_search_plugin_guide.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/plugins/google_search_plugin_guide.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/plugins/searxng_plugin_guide.en.mdx": "2026-02-26T18:17:53+08:00", + "document/content/docs/introduction/guide/plugins/searxng_plugin_guide.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/plugins/searxng_plugin_guide.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/plugins/upload_system_tool.en.mdx": "2026-02-26T18:17:53+08:00", + "document/content/docs/introduction/guide/plugins/upload_system_tool.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/plugins/upload_system_tool.mdx": "2025-11-04T16:58:12+08:00", - "document/content/docs/introduction/guide/team_permissions/customDomain.en.mdx": "2026-02-26T18:17:53+08:00", + "document/content/docs/introduction/guide/team_permissions/customDomain.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/team_permissions/customDomain.mdx": "2025-12-10T20:07:05+08:00", - "document/content/docs/introduction/guide/team_permissions/invitation_link.en.mdx": "2026-02-26T18:17:53+08:00", + "document/content/docs/introduction/guide/team_permissions/invitation_link.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/team_permissions/invitation_link.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/guide/team_permissions/team_roles_permissions.en.mdx": "2026-02-26T18:17:53+08:00", + "document/content/docs/introduction/guide/team_permissions/team_roles_permissions.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/guide/team_permissions/team_roles_permissions.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/introduction/index.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/introduction/index.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/introduction/index.mdx": "2026-02-26T00:26:52+08:00", - "document/content/docs/openapi/app.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/openapi/app.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/openapi/app.mdx": "2026-02-12T18:45:30+08:00", - "document/content/docs/openapi/chat.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/openapi/chat.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/openapi/chat.mdx": "2026-02-12T18:45:30+08:00", - "document/content/docs/openapi/dataset.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/openapi/dataset.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/openapi/dataset.mdx": "2026-02-12T18:45:30+08:00", - "document/content/docs/openapi/index.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/openapi/index.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/openapi/index.mdx": "2026-02-12T18:45:30+08:00", - "document/content/docs/openapi/intro.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/openapi/intro.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/openapi/intro.mdx": "2026-02-12T18:45:30+08:00", - "document/content/docs/openapi/share.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/openapi/share.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/openapi/share.mdx": "2026-02-12T18:45:30+08:00", - "document/content/docs/protocol/index.en.mdx": "2026-02-26T17:32:58+08:00", + "document/content/docs/protocol/index.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/protocol/index.mdx": "2025-07-30T15:38:30+08:00", - "document/content/docs/protocol/open-source.en.mdx": "2025-08-05T23:20:39+08:00", + "document/content/docs/protocol/open-source.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/protocol/open-source.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/protocol/privacy.en.mdx": "2025-12-15T23:36:54+08:00", + "document/content/docs/protocol/privacy.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/protocol/privacy.mdx": "2025-12-15T23:36:54+08:00", - "document/content/docs/protocol/terms.en.mdx": "2025-12-15T23:36:54+08:00", + "document/content/docs/protocol/terms.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/protocol/terms.mdx": "2025-12-15T23:36:54+08:00", - "document/content/docs/toc.en.mdx": "2026-02-26T20:05:36+08:00", - "document/content/docs/toc.mdx": "2026-02-24T13:48:31+08:00", - "document/content/docs/upgrading/4-10/4100.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/toc.en.mdx": "2026-02-26T22:14:30+08:00", + "document/content/docs/toc.mdx": "2026-02-26T15:08:45+08:00", + "document/content/docs/upgrading/4-10/4100.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-10/4100.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-10/4101.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-10/4101.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-10/4101.mdx": "2025-09-08T20:07:20+08:00", - "document/content/docs/upgrading/4-11/4110.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-11/4110.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-11/4110.mdx": "2026-02-26T00:26:52+08:00", - "document/content/docs/upgrading/4-11/4111.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-11/4111.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-11/4111.mdx": "2025-08-07T22:49:09+08:00", - "document/content/docs/upgrading/4-12/4120.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-12/4120.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-12/4120.mdx": "2025-09-07T14:41:48+08:00", - "document/content/docs/upgrading/4-12/4121.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-12/4121.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-12/4121.mdx": "2025-09-07T14:41:48+08:00", - "document/content/docs/upgrading/4-12/4122.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-12/4122.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-12/4122.mdx": "2025-09-07T14:41:48+08:00", - "document/content/docs/upgrading/4-12/4123.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-12/4123.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-12/4123.mdx": "2025-09-07T20:55:14+08:00", - "document/content/docs/upgrading/4-12/4124.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-12/4124.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-12/4124.mdx": "2025-09-17T22:29:56+08:00", - "document/content/docs/upgrading/4-13/4130.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-13/4130.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-13/4130.mdx": "2025-11-04T15:06:39+08:00", - "document/content/docs/upgrading/4-13/4131.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-13/4131.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-13/4131.mdx": "2025-09-30T15:47:06+08:00", - "document/content/docs/upgrading/4-13/4132.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-13/4132.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-13/4132.mdx": "2025-12-15T11:50:00+08:00", - "document/content/docs/upgrading/4-14/4140.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-14/4140.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-14/4140.mdx": "2025-11-06T15:43:00+08:00", - "document/content/docs/upgrading/4-14/4141.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-14/4141.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-14/4141.mdx": "2025-12-31T09:54:29+08:00", - "document/content/docs/upgrading/4-14/4142.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-14/4142.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-14/4142.mdx": "2025-11-18T19:27:14+08:00", - "document/content/docs/upgrading/4-14/4143.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-14/4143.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-14/4143.mdx": "2026-02-04T14:27:58+08:00", - "document/content/docs/upgrading/4-14/4144.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-14/4144.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-14/4144.mdx": "2026-02-04T14:27:58+08:00", - "document/content/docs/upgrading/4-14/4145.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-14/4145.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-14/4145.mdx": "2026-01-18T23:59:15+08:00", - "document/content/docs/upgrading/4-14/41451.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-14/41451.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-14/41451.mdx": "2026-01-20T11:53:27+08:00", - "document/content/docs/upgrading/4-14/4146.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-14/4146.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-14/4146.mdx": "2026-02-12T16:37:50+08:00", - "document/content/docs/upgrading/4-14/4147.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-14/4147.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-14/4147.mdx": "2026-02-26T18:14:55+08:00", - "document/content/docs/upgrading/4-14/4148.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-14/4148.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-14/4148.mdx": "2026-02-25T18:28:16+08:00", - "document/content/docs/upgrading/4-8/40.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/40.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/40.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/41.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/41.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/41.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/42.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/42.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/42.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/421.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/421.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/421.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/43.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/43.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/43.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/44.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/44.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/44.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/441.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/441.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/441.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/442.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/442.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/442.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/445.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/445.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/445.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/446.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/446.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/446.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/447.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/447.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/447.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/45.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/45.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/45.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/upgrading/4-8/451.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/451.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/451.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/452.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/452.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/452.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/46.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/46.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/46.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/upgrading/4-8/461.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/461.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/461.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/462.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/462.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/462.mdx": "2025-08-04T22:07:52+08:00", - "document/content/docs/upgrading/4-8/463.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/463.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/463.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/464.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/464.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/464.mdx": "2026-02-12T18:45:30+08:00", - "document/content/docs/upgrading/4-8/465.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/465.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/465.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/upgrading/4-8/466.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/466.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/466.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/upgrading/4-8/467.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/467.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/467.mdx": "2026-02-12T18:45:30+08:00", - "document/content/docs/upgrading/4-8/468.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/468.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/468.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/upgrading/4-8/469.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/469.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/469.mdx": "2026-02-12T18:45:30+08:00", - "document/content/docs/upgrading/4-8/47.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/47.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/47.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/upgrading/4-8/471.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/471.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/471.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/upgrading/4-8/48.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/48.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/48.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/481.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/481.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/481.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/4810.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/4810.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/4810.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/4811.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/4811.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/4811.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/4812.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/4812.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/4812.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/4813.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/4813.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/4813.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/4814.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/4814.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/4814.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/4815.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/4815.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/4815.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/upgrading/4-8/4816.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/4816.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/4816.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/upgrading/4-8/4817.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/4817.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/4817.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/4818.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/4818.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/4818.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/4819.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/4819.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/4819.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/482.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/482.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/482.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/4820.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/4820.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/4820.mdx": "2025-08-05T23:20:39+08:00", - "document/content/docs/upgrading/4-8/4821.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/4821.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/4821.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/4822.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/4822.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/4822.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/4823.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/4823.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/4823.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/483.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/483.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/483.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/484.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/484.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/484.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/485.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/485.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/485.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/486.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/486.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/486.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/487.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/487.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/487.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/488.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/488.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/488.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-8/489.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-8/489.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-8/489.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-9/490.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-9/490.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-9/490.mdx": "2026-02-12T18:45:30+08:00", - "document/content/docs/upgrading/4-9/491.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-9/491.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-9/491.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-9/4910.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-9/4910.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-9/4910.mdx": "2025-08-04T22:07:52+08:00", - "document/content/docs/upgrading/4-9/4911.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-9/4911.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-9/4911.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-9/4912.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-9/4912.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-9/4912.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-9/4913.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-9/4913.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-9/4913.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-9/4914.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-9/4914.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-9/4914.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-9/492.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-9/492.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-9/492.mdx": "2026-02-12T18:45:30+08:00", - "document/content/docs/upgrading/4-9/493.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-9/493.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-9/493.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-9/494.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-9/494.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-9/494.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-9/495.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-9/495.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-9/495.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-9/496.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-9/496.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-9/496.mdx": "2025-08-04T22:07:52+08:00", - "document/content/docs/upgrading/4-9/497.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-9/497.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-9/497.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-9/498.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-9/498.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-9/498.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/4-9/499.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/4-9/499.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/4-9/499.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/upgrading/index.en.mdx": "2026-02-26T20:05:36+08:00", + "document/content/docs/upgrading/index.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/upgrading/index.mdx": "2025-08-02T19:38:37+08:00", - "document/content/docs/use-cases/app-cases/dalle3.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/use-cases/app-cases/dalle3.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/app-cases/dalle3.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/use-cases/app-cases/english_essay_correction_bot.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/use-cases/app-cases/english_essay_correction_bot.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/app-cases/english_essay_correction_bot.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/use-cases/app-cases/feishu_webhook.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/use-cases/app-cases/feishu_webhook.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/app-cases/feishu_webhook.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/use-cases/app-cases/fixingEvidence.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/use-cases/app-cases/fixingEvidence.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/app-cases/fixingEvidence.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/use-cases/app-cases/google_search.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/use-cases/app-cases/google_search.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/app-cases/google_search.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/use-cases/app-cases/lab_appointment.en.mdx": "2026-02-26T17:42:53+08:00", + "document/content/docs/use-cases/app-cases/lab_appointment.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/app-cases/lab_appointment.mdx": "2025-12-10T20:07:05+08:00", - "document/content/docs/use-cases/app-cases/multi_turn_translation_bot.en.mdx": "2026-02-26T17:32:58+08:00", + "document/content/docs/use-cases/app-cases/multi_turn_translation_bot.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/app-cases/multi_turn_translation_bot.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/use-cases/app-cases/submit_application_template.en.mdx": "2026-02-26T18:38:22+08:00", + "document/content/docs/use-cases/app-cases/submit_application_template.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/app-cases/submit_application_template.mdx": "2026-01-27T15:19:19+08:00", - "document/content/docs/use-cases/app-cases/translate-subtitle-using-gpt.en.mdx": "2026-02-26T18:38:22+08:00", + "document/content/docs/use-cases/app-cases/translate-subtitle-using-gpt.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/app-cases/translate-subtitle-using-gpt.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/use-cases/external-integration/dingtalk.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/use-cases/external-integration/dingtalk.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/external-integration/dingtalk.mdx": "2025-07-23T21:35:03+08:00", - "document/content/docs/use-cases/external-integration/feishu.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/use-cases/external-integration/feishu.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/external-integration/feishu.mdx": "2025-07-24T14:23:04+08:00", - "document/content/docs/use-cases/external-integration/official_account.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/use-cases/external-integration/official_account.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/external-integration/official_account.mdx": "2026-02-26T00:26:52+08:00", - "document/content/docs/use-cases/external-integration/openapi.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/use-cases/external-integration/openapi.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/external-integration/openapi.mdx": "2026-02-12T18:45:30+08:00", - "document/content/docs/use-cases/external-integration/wecom.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/use-cases/external-integration/wecom.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/external-integration/wecom.mdx": "2025-12-10T20:07:05+08:00", - "document/content/docs/use-cases/index.en.mdx": "2026-02-26T17:28:07+08:00", + "document/content/docs/use-cases/index.en.mdx": "2026-02-26T22:14:30+08:00", "document/content/docs/use-cases/index.mdx": "2025-07-24T14:23:04+08:00" } \ No newline at end of file diff --git a/packages/global/common/error/utils.ts b/packages/global/common/error/utils.ts index c6ed2e781c..a92add7743 100644 --- a/packages/global/common/error/utils.ts +++ b/packages/global/common/error/utils.ts @@ -12,6 +12,7 @@ export const getErrText = (err: any, def = ''): any => { err?.response?.msg || err?.msg || err?.error || + err?.code || def; if (ERROR_RESPONSE[msg]) { diff --git a/packages/global/core/workflow/template/system/sandbox/constants.ts b/packages/global/core/workflow/template/system/sandbox/constants.ts index c210a140f0..6a373235bb 100644 --- a/packages/global/core/workflow/template/system/sandbox/constants.ts +++ b/packages/global/core/workflow/template/system/sandbox/constants.ts @@ -1,5 +1,5 @@ export const JS_TEMPLATE = `function main({data1, data2}){ - + return { result: data1, data2 @@ -7,10 +7,11 @@ export const JS_TEMPLATE = `function main({data1, data2}){ }`; export const PY_TEMPLATE = `def main(data1, data2): - return { - "result": data1, - "data2": data2 - } + + return { + "result": data1, + "data2": data2 + } `; export enum SandboxCodeTypeEnum { diff --git a/packages/global/core/workflow/template/system/sandbox/index.ts b/packages/global/core/workflow/template/system/sandbox/index.ts index 1aee7972f0..424c5f9864 100644 --- a/packages/global/core/workflow/template/system/sandbox/index.ts +++ b/packages/global/core/workflow/template/system/sandbox/index.ts @@ -25,10 +25,10 @@ export const CodeNode: FlowNodeTemplateType = { avatarLinear: 'core/workflow/template/codeRunLinear', colorSchema: 'lime', name: i18nT('workflow:code_execution'), - intro: i18nT('workflow:execute_a_simple_script_code_usually_for_complex_data_processing'), + intro: i18nT('workflow:code_sandbox_intro'), showStatus: true, catchError: false, - courseUrl: '/docs/introduction/guide/dashboard/workflow/sandbox/', + courseUrl: '/docs/introduction/guide/dashboard/workflow/sandbox-v2', inputs: [ { ...Input_Template_DynamicInput, diff --git a/packages/service/core/workflow/dispatch/tools/codeSandbox.ts b/packages/service/core/workflow/dispatch/tools/codeSandbox.ts index 11314f5f79..178906cbee 100644 --- a/packages/service/core/workflow/dispatch/tools/codeSandbox.ts +++ b/packages/service/core/workflow/dispatch/tools/codeSandbox.ts @@ -1,10 +1,9 @@ import type { ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/type'; import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { type DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type'; -import { axios } from '../../../../common/api/axios'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; -import { SandboxCodeTypeEnum } from '@fastgpt/global/core/workflow/template/system/sandbox/constants'; import { getErrText } from '@fastgpt/global/common/error/utils'; +import { codeSandbox } from '../../../../thirdProvider/codeSandbox'; type RunCodeType = ModuleDispatchProps<{ [NodeInputKeyEnum.codeType]: string; @@ -22,44 +21,6 @@ type RunCodeResponse = DispatchNodeResultType< } >; -export const runCode = async ({ - codeType, - code, - variables -}: { - codeType: string; - code: string; - variables: Record; -}): Promise<{ - codeReturn: Record; - log: string; -}> => { - const url = (() => { - if (codeType == SandboxCodeTypeEnum.py) { - return `${process.env.SANDBOX_URL}/sandbox/python`; - } else { - return `${process.env.SANDBOX_URL}/sandbox/js`; - } - })(); - - const { data: runResult } = await axios.post<{ - success: boolean; - data: { - codeReturn: Record; - log: string; - }; - }>(url, { - code, - variables - }); - - if (!runResult.success) { - return Promise.reject('Run code failed'); - } - - return runResult.data; -}; - export const dispatchCodeSandbox = async (props: RunCodeType): Promise => { const { node: { catchError }, @@ -79,7 +40,11 @@ export const dispatchCodeSandbox = async (props: RunCodeType): Promise { + const data = response.data; + if (!data.success) { + return Promise.reject(new Error(data.message || 'Request code sandbox failed')); + } + return response.data; + }, + (error) => { + return Promise.reject(error); + } + ); + } + + async getPackages() { + const { data } = await this.client.get('/modules'); + return data; + } + + async runCode({ + codeType, + code, + variables + }: { + codeType: string; + code: string; + variables: Record; + }) { + const url = (() => { + if (codeType == SandboxCodeTypeEnum.py) { + return `/python`; + } else { + return `/js`; + } + })(); + + const { data } = await this.client.post<{ + codeReturn: Record; + log: string; + }>(url, { code, variables }); + + return data; + } +} + +export const codeSandbox = new CodeSandbox(); diff --git a/packages/web/components/common/Textarea/CodeEditor/Editor.tsx b/packages/web/components/common/Textarea/CodeEditor/Editor.tsx index aab81c6186..f91234cd7c 100644 --- a/packages/web/components/common/Textarea/CodeEditor/Editor.tsx +++ b/packages/web/components/common/Textarea/CodeEditor/Editor.tsx @@ -4,6 +4,9 @@ import { Box, type BoxProps } from '@chakra-ui/react'; import MyIcon from '../../Icon'; import { getWebReqUrl } from '../../../../common/system/utils'; import usePythonCompletion from './usePythonCompletion'; +import useJSCompletion from './useJSCompletion'; +import useSystemHelperCompletion from './useSystemHelperCompletion'; + loader.config({ paths: { vs: getWebReqUrl('/js/monaco-editor.0.45.0/vs') } }); @@ -44,7 +47,12 @@ const defaultOptions = { scrollBeyondLastLine: false, folding: true, overviewRulerBorder: false, - tabSize: 2 + tabSize: 2, + wordBasedSuggestions: 'off', + quickSuggestions: { other: 'on', comments: false, strings: false }, + suggest: { + showWords: false + } }; const MyEditor = ({ @@ -55,7 +63,7 @@ const MyEditor = ({ variables = [], defaultHeight = 200, onOpenModal, - language = 'typescript', + language = 'javascript', options, ...props }: Props) => { @@ -71,6 +79,8 @@ const MyEditor = ({ ); const registerPythonCompletion = usePythonCompletion(); + const registerJSCompletion = useJSCompletion(); + const registerSystemHelperCompletion = useSystemHelperCompletion(); const handleMouseDown = useCallback((e: React.MouseEvent) => { initialY.current = e.clientY; @@ -139,8 +149,10 @@ const MyEditor = ({ } }); registerPythonCompletion(monaco); + registerJSCompletion(monaco); + registerSystemHelperCompletion(monaco); }, - [registerPythonCompletion] + [registerPythonCompletion, registerJSCompletion, registerSystemHelperCompletion] ); return ( diff --git a/packages/web/components/common/Textarea/CodeEditor/index.tsx b/packages/web/components/common/Textarea/CodeEditor/index.tsx index 1a3325e16c..a404de3eef 100644 --- a/packages/web/components/common/Textarea/CodeEditor/index.tsx +++ b/packages/web/components/common/Textarea/CodeEditor/index.tsx @@ -12,10 +12,10 @@ function getLanguage(language: string | undefined): string { fullName = 'python'; break; case 'js': - fullName = 'typescript'; + fullName = 'javascript'; break; default: - fullName = `typescript`; + fullName = `javascript`; break; } return fullName; diff --git a/packages/web/components/common/Textarea/CodeEditor/useJSCompletion.ts b/packages/web/components/common/Textarea/CodeEditor/useJSCompletion.ts new file mode 100644 index 0000000000..5f6eceebd2 --- /dev/null +++ b/packages/web/components/common/Textarea/CodeEditor/useJSCompletion.ts @@ -0,0 +1,152 @@ +import { type Monaco } from '@monaco-editor/react'; +import { useCallback } from 'react'; + +/** + * Sandbox runtime type declarations. + * Matches the actual globals exposed by FastGPT sandbox worker (worker.ts). + */ +export const SANDBOX_GLOBALS_LIB = ` +// ===== SystemHelper ===== +interface SystemHelperHttpRequestOptions { + /** HTTP method, default: 'GET' */ + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + /** Custom request headers */ + headers?: Record; + /** Request body (objects auto JSON-serialized) */ + body?: any; + /** Timeout in seconds, max 60 */ + timeout?: number; +} +interface SystemHelperHttpResponse { + status: number; + statusText: string; + headers: Record; + data: any; +} +interface ISystemHelper { + /** Send an HTTP request (SSRF protected, max 30 per run). */ + httpRequest(url: string, options?: SystemHelperHttpRequestOptions): Promise; +} +declare const SystemHelper: ISystemHelper; + +// ===== Node.js / Bun runtime globals ===== +interface BufferConstructor { + alloc(size: number, fill?: string | number, encoding?: string): Buffer; + from(data: string | ArrayBuffer | number[] | Buffer, encoding?: string): Buffer; + concat(list: Buffer[], totalLength?: number): Buffer; + isBuffer(obj: any): obj is Buffer; + byteLength(string: string, encoding?: string): number; +} +interface Buffer extends Uint8Array { + toString(encoding?: string, start?: number, end?: number): string; + toJSON(): { type: 'Buffer'; data: number[] }; + slice(start?: number, end?: number): Buffer; + length: number; +} +declare const Buffer: BufferConstructor; + +declare function setTimeout(callback: (...args: any[]) => void, ms?: number, ...args: any[]): any; +declare function setInterval(callback: (...args: any[]) => void, ms?: number, ...args: any[]): any; +declare function clearTimeout(id: any): void; +declare function clearInterval(id: any): void; + +// ===== Sandbox console (safe, only log) ===== +interface SandboxConsole { + log(...args: any[]): void; +} +declare const console: SandboxConsole; + +// ===== require (allowed modules only) ===== +declare function require(module: 'lodash'): typeof import('lodash'); +declare function require(module: 'moment'): typeof import('moment'); +declare function require(module: 'dayjs'): typeof import('dayjs'); +declare function require(module: 'crypto-js'): any; +declare function require(module: 'uuid'): { v4: () => string; v1: () => string }; +declare function require(module: 'qs'): any; +declare function require(module: string): any; +`; + +let monacoInstance: Monaco | null = null; + +const useJSCompletion = () => { + return useCallback((monaco: Monaco) => { + if (monacoInstance === monaco) return; + monacoInstance = monaco; + + const compilerOptions = { + target: monaco.languages.typescript.ScriptTarget.ESNext, + allowNonTsExtensions: true, + allowJs: true + }; + monaco.languages.typescript.javascriptDefaults.setCompilerOptions(compilerOptions); + monaco.languages.typescript.typescriptDefaults.setCompilerOptions(compilerOptions); + + // Inject sandbox type declarations — must follow setCompilerOptions + monaco.languages.typescript.javascriptDefaults.addExtraLib( + SANDBOX_GLOBALS_LIB, + 'ts:filename/sandbox-globals.d.ts' + ); + monaco.languages.typescript.typescriptDefaults.addExtraLib( + SANDBOX_GLOBALS_LIB, + 'ts:filename/sandbox-globals.d.ts' + ); + + // Disable semantic validation — sandbox has custom module resolution (safeRequire) + // that TS cannot model. Keep syntax validation for bracket/paren errors. + const diagnosticsOptions = { + noSemanticValidation: true, + noSyntaxValidation: false, + noSuggestionDiagnostics: true + }; + monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions(diagnosticsOptions); + monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions(diagnosticsOptions); + + monaco.languages.registerCompletionItemProvider('javascript', { + triggerCharacters: ['.'], + provideCompletionItems: (model, position) => { + const wordInfo = model.getWordUntilPosition(position); + const currentWordPrefix = wordInfo.word; + + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: wordInfo.startColumn, + endColumn: wordInfo.endColumn + }; + + const suggestions = [ + { + label: 'console', + kind: monaco.languages.CompletionItemKind.Module, + insertText: 'console', + documentation: 'Console output (sandbox safe version, only log).', + range + }, + { + label: 'require', + kind: monaco.languages.CompletionItemKind.Function, + insertText: "require('${1:module}')", + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: 'Require allowed modules: lodash, moment, dayjs, crypto-js, uuid, qs.', + range + }, + { + label: 'Buffer', + kind: monaco.languages.CompletionItemKind.Class, + insertText: 'Buffer', + documentation: 'Node.js Buffer for binary data.', + range + } + ]; + + return { + suggestions: suggestions.filter((item) => + item.label.toLowerCase().startsWith(currentWordPrefix.toLowerCase()) + ) + }; + } + }); + }, []); +}; + +export default useJSCompletion; diff --git a/packages/web/components/common/Textarea/CodeEditor/usePythonCompletion.ts b/packages/web/components/common/Textarea/CodeEditor/usePythonCompletion.ts index 8a38dd2bd5..01a9949c10 100644 --- a/packages/web/components/common/Textarea/CodeEditor/usePythonCompletion.ts +++ b/packages/web/components/common/Textarea/CodeEditor/usePythonCompletion.ts @@ -1,17 +1,23 @@ import { type Monaco } from '@monaco-editor/react'; import { useCallback } from 'react'; + let monacoInstance: Monaco | null = null; + const usePythonCompletion = () => { return useCallback((monaco: Monaco) => { if (monacoInstance === monaco) return; monacoInstance = monaco; monaco.languages.registerCompletionItemProvider('python', { + triggerCharacters: ['_'], provideCompletionItems: (model, position) => { const wordInfo = model.getWordUntilPosition(position); const currentWordPrefix = wordInfo.word; - const lineContent = model.getLineContent(position.lineNumber); + const textBeforeCursor = lineContent.slice(0, position.column - 1); + + // Skip built-ins when in member access context (e.g. "foo.") + if (textBeforeCursor.endsWith('.')) return { suggestions: [] }; const range = { startLineNumber: position.lineNumber, @@ -20,37 +26,19 @@ const usePythonCompletion = () => { endColumn: wordInfo.endColumn }; - const baseSuggestions = [ - { - label: 'len', - kind: monaco.languages.CompletionItemKind.Function, - insertText: 'len()', - documentation: 'get length of object', - range, - sortText: 'a' - } - ]; - - const filtered = baseSuggestions.filter((item) => - item.label.toLowerCase().startsWith(currentWordPrefix.toLowerCase()) - ); - + // import line — suggest common packages if (lineContent.startsWith('import')) { const importLength = 'import'.length; const afterImport = lineContent.slice(importLength); const spaceMatch = afterImport.match(/^\s*/); const spaceLength = spaceMatch ? spaceMatch[0].length : 0; - const startReplaceCol = importLength + spaceLength + 1; - const currentCol = position.column; - const replaceRange = new monaco.Range( position.lineNumber, startReplaceCol, position.lineNumber, - currentCol + position.column ); - const needsSpace = spaceLength === 0; return { suggestions: [ @@ -73,9 +61,40 @@ const usePythonCompletion = () => { }; } - return { suggestions: filtered }; - }, - triggerCharacters: ['.', '_'] + // General Python built-ins + const suggestions = [ + { + label: 'len', + kind: monaco.languages.CompletionItemKind.Function, + insertText: 'len()', + documentation: 'Return the length of an object.', + range, + sortText: 'a' + }, + { + label: 'print', + kind: monaco.languages.CompletionItemKind.Function, + insertText: 'print(${1:value})', + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: 'Print values to stdout.', + range + }, + { + label: 'range', + kind: monaco.languages.CompletionItemKind.Function, + insertText: 'range(${1:stop})', + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: 'Return a range object.', + range + } + ]; + + return { + suggestions: suggestions.filter((item) => + item.label.toLowerCase().startsWith(currentWordPrefix.toLowerCase()) + ) + }; + } }); }, []); }; diff --git a/packages/web/components/common/Textarea/CodeEditor/useSystemHelperCompletion.ts b/packages/web/components/common/Textarea/CodeEditor/useSystemHelperCompletion.ts new file mode 100644 index 0000000000..8375fb2fd7 --- /dev/null +++ b/packages/web/components/common/Textarea/CodeEditor/useSystemHelperCompletion.ts @@ -0,0 +1,94 @@ +import { type Monaco } from '@monaco-editor/react'; +import { useCallback } from 'react'; + +let monacoInstance: Monaco | null = null; + +const useSystemHelperCompletion = () => { + return useCallback((monaco: Monaco) => { + if (monacoInstance === monaco) return; + monacoInstance = monaco; + + const buildSuggestions = ( + monaco: Monaco, + model: Parameters< + Parameters< + typeof monaco.languages.registerCompletionItemProvider + >[1]['provideCompletionItems'] + >[0], + position: Parameters< + Parameters< + typeof monaco.languages.registerCompletionItemProvider + >[1]['provideCompletionItems'] + >[1], + memberSnippet: string + ) => { + const lineContent = model.getLineContent(position.lineNumber); + const textBeforeCursor = lineContent.slice(0, position.column - 1); + + // After "SystemHelper." — suggest members + if (textBeforeCursor.endsWith('SystemHelper.')) { + return { + suggestions: [ + { + label: 'httpRequest', + kind: monaco.languages.CompletionItemKind.Method, + insertText: memberSnippet, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: 'Send an HTTP request. Returns { status, data }.', + range: { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: position.column, + endColumn: position.column + } + } + ] + }; + } + + // Suggest "SystemHelper" as a global identifier + const wordInfo = model.getWordUntilPosition(position); + if (wordInfo.word.length > 0 && 'SystemHelper'.startsWith(wordInfo.word)) { + return { + suggestions: [ + { + label: 'SystemHelper', + kind: monaco.languages.CompletionItemKind.Module, + insertText: 'SystemHelper', + documentation: 'Built-in helper utilities provided by FastGPT sandbox.', + range: { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: wordInfo.startColumn, + endColumn: wordInfo.endColumn + } + } + ] + }; + } + + return { suggestions: [] }; + }; + + // JS/TS completion provider + const jsSnippet = + "httpRequest(${1:url}, {\n\tmethod: '${2:GET}',\n\theaders: {},\n\tbody: null,\n\ttimeout: 60\n})"; + for (const lang of ['javascript', 'typescript'] as const) { + monaco.languages.registerCompletionItemProvider(lang, { + triggerCharacters: ['.'], + provideCompletionItems: (model, position) => + buildSuggestions(monaco, model, position, jsSnippet) + }); + } + + // Python completion provider + const pySnippet = 'httpRequest(${1:url}, method="${2:GET}", headers={}, timeout=${3:60})'; + monaco.languages.registerCompletionItemProvider('python', { + triggerCharacters: ['.'], + provideCompletionItems: (model, position) => + buildSuggestions(monaco, model, position, pySnippet) + }); + }, []); +}; + +export default useSystemHelperCompletion; diff --git a/packages/web/i18n/en/workflow.json b/packages/web/i18n/en/workflow.json index 568c8ec075..3281e1cb38 100644 --- a/packages/web/i18n/en/workflow.json +++ b/packages/web/i18n/en/workflow.json @@ -30,7 +30,10 @@ "code.Reset template": "Reset Template", "code.Reset template confirm": "Confirm reset code template? This will reset all inputs and outputs to template values. Please save your current code.", "code.Switch language confirm": "Switching the language will reset the code, will it continue?", + "code_allow_packages": "Available dependencies", + "code_allow_packages_func": "Available packages: {{modules}}\nGlobal functions: {{globals}}", "code_execution": "Code Sandbox", + "code_sandbox_intro": "Execute a piece of script code, usually used for complex data processing.", "collection_metadata_filter": "Collection Metadata Filter", "complete_extraction_result": "Complete Extraction Result", "complete_extraction_result_description": "A JSON string, e.g., {\"name\":\"YY\",\"Time\":\"2023/7/2 18:00\"}", @@ -59,7 +62,6 @@ "error_catch": "Error catch", "error_info_returns_empty_on_success": "Error information of code execution, returns empty on success", "error_text": "Error text", - "execute_a_simple_script_code_usually_for_complex_data_processing": "Execute a simple script code, usually for complex data processing.", "execute_different_branches_based_on_conditions": "Execute different branches based on conditions.", "execution_error": "Execution Error", "external_variables": "External variables", @@ -178,7 +180,6 @@ "select_default_option": "Select the default value", "special_array_format": "Special array format, returns an empty array when the search result is empty.", "start_with": "Starts With", - "support_code_language": "Support import list: pandas,numpy", "target_fields_description": "A target field consists of 'description' and 'key'. Multiple target fields can be extracted.", "template.agent": "Agent", "template.agent_intro": "Automatically select one or more functional blocks for calling through the AI model, or call plugins.", diff --git a/packages/web/i18n/zh-CN/workflow.json b/packages/web/i18n/zh-CN/workflow.json index c633620cb6..367b18da52 100644 --- a/packages/web/i18n/zh-CN/workflow.json +++ b/packages/web/i18n/zh-CN/workflow.json @@ -30,7 +30,10 @@ "code.Reset template": "还原模板", "code.Reset template confirm": "确认还原代码模板?将会重置所有输入和输出至模板值,请注意保存当前代码。", "code.Switch language confirm": "切换语言将重置代码,是否继续?", + "code_allow_packages": "可用依赖", + "code_allow_packages_func": "可用依赖: {{modules}}\n全局函数: {{globals}}", "code_execution": "代码运行", + "code_sandbox_intro": "执行一段脚本代码,通常用于进行复杂的数据处理。", "collection_metadata_filter": "集合元数据过滤", "complete_extraction_result": "完整提取结果", "complete_extraction_result_description": "一个 JSON 字符串,例如:{\"name:\":\"YY\",\"Time\":\"2023/7/2 18:00\"}", @@ -59,7 +62,6 @@ "error_catch": "报错捕获", "error_info_returns_empty_on_success": "代码运行错误信息,成功时返回空", "error_text": "错误信息", - "execute_a_simple_script_code_usually_for_complex_data_processing": "执行一段简单的脚本代码,通常用于进行复杂的数据处理。", "execute_different_branches_based_on_conditions": "根据一定的条件,执行不同的分支。", "execution_error": "运行错误", "external_variables": "外部变量", @@ -178,7 +180,6 @@ "select_default_option": "选择默认值", "special_array_format": "特殊数组格式,搜索结果为空时,返回空数组。", "start_with": "开始为", - "support_code_language": "支持import列表:pandas,numpy", "target_fields_description": "由 '描述' 和 'key' 组成一个目标字段,可提取多个目标字段", "template.agent": "工具调用", "template.agent_intro": "由 AI 自主决定工具调用。", diff --git a/packages/web/i18n/zh-Hant/workflow.json b/packages/web/i18n/zh-Hant/workflow.json index 06dae1b8fa..4099c857e5 100644 --- a/packages/web/i18n/zh-Hant/workflow.json +++ b/packages/web/i18n/zh-Hant/workflow.json @@ -30,7 +30,10 @@ "code.Reset template": "重設範本", "code.Reset template confirm": "確定要重設程式碼範本嗎?這將會把所有輸入和輸出重設為範本值。請儲存您目前的程式碼。", "code.Switch language confirm": "切換語言將重設代碼,是否繼續?", + "code_allow_packages": "可用依賴", + "code_allow_packages_func": "可用依賴: {{modules}}\n全域函數: {{globals}}", "code_execution": "程式碼執行", + "code_sandbox_intro": "執行一段腳本程式碼,通常用於進行複雜的資料處理。", "collection_metadata_filter": "資料集詮釋資料篩選器", "complete_extraction_result": "完整擷取結果", "complete_extraction_result_description": "一個 JSON 字串,例如:{\"name\":\"YY\",\"Time\":\"2023/7/2 18:00\"}", @@ -59,7 +62,6 @@ "error_catch": "報錯捕獲", "error_info_returns_empty_on_success": "程式碼執行錯誤資訊,成功時回傳空值", "error_text": "錯誤訊息", - "execute_a_simple_script_code_usually_for_complex_data_processing": "執行一段簡單的腳本程式碼,通常用於複雜的資料處理。", "execute_different_branches_based_on_conditions": "根據條件執行不同的分支。", "execution_error": "執行錯誤", "external_variables": "外部變量", @@ -178,7 +180,6 @@ "select_default_option": "選擇默認值", "special_array_format": "特殊陣列格式,搜尋結果為空時,回傳空陣列。", "start_with": "開頭為", - "support_code_language": "支援 import 列表:pandas,numpy", "target_fields_description": "由「描述」和「鍵值」組成一個目標欄位,可以擷取多個目標欄位", "template.agent": "工具调用", "template.agent_intro": "透過 AI 模型自動選擇一或多個功能區塊進行呼叫,也可以呼叫外掛程式。", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cef07a09dd..75de7231d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -894,97 +894,6 @@ importers: specifier: ^5.0.1 version: 5.0.1 - projects/sandbox: - dependencies: - '@fastify/static': - specifier: ^7.0.4 - version: 7.0.4 - '@nestjs/common': - specifier: ^10.4.16 - version: 10.4.16(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': - specifier: ^10.0.0 - version: 10.4.15(@nestjs/common@10.4.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/platform-fastify': - specifier: ^10.3.8 - version: 10.4.15(@fastify/static@7.0.4)(@nestjs/common@10.4.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.15(@nestjs/common@10.4.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/swagger': - specifier: ^7.3.1 - version: 7.4.2(@fastify/static@7.0.4)(@nestjs/common@10.4.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.15(@nestjs/common@10.4.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) - dayjs: - specifier: ^1.11.7 - version: 1.11.13 - fastify: - specifier: ^4.29.1 - version: 4.29.1 - isolated-vm: - specifier: ^4.7.2 - version: 4.7.2 - node-gyp: - specifier: ^10.1.0 - version: 10.3.1 - reflect-metadata: - specifier: ^0.2.0 - version: 0.2.2 - rxjs: - specifier: ^7.8.1 - version: 7.8.2 - tiktoken: - specifier: 1.0.17 - version: 1.0.17 - devDependencies: - '@nestjs/cli': - specifier: ^10.0.0 - version: 10.4.9 - '@nestjs/schematics': - specifier: ^10.0.0 - version: 10.2.3(chokidar@3.6.0)(typescript@5.8.2) - '@nestjs/testing': - specifier: ^10.0.0 - version: 10.4.15(@nestjs/common@10.4.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.15(@nestjs/common@10.4.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@types/jest': - specifier: ^29.5.2 - version: 29.5.14 - '@types/node': - specifier: ^20.14.2 - version: 20.17.24 - '@types/supertest': - specifier: ^6.0.0 - version: 6.0.2 - '@typescript-eslint/eslint-plugin': - specifier: ^6.21.0 - version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.2))(eslint@8.56.0)(typescript@5.8.2) - '@typescript-eslint/parser': - specifier: ^6.21.0 - version: 6.21.0(eslint@8.56.0)(typescript@5.8.2) - eslint: - specifier: 8.56.0 - version: 8.56.0 - jest: - specifier: ^29.5.0 - version: 29.7.0(@types/node@20.17.24)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2)) - source-map-support: - specifier: ^0.5.21 - version: 0.5.21 - supertest: - specifier: ^6.3.3 - version: 6.3.4 - ts-jest: - specifier: ^29.1.0 - version: 29.2.6(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(jest@29.7.0(@types/node@20.17.24)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.2)))(typescript@5.8.2) - ts-loader: - specifier: ^9.4.3 - version: 9.5.2(typescript@5.8.2)(webpack@5.97.1) - ts-node: - specifier: ^10.9.1 - version: 10.9.2(@types/node@20.17.24)(typescript@5.8.2) - tsconfig-paths: - specifier: ^4.2.0 - version: 4.2.0 - typescript: - specifier: ^5.1.3 - version: 5.8.2 - scripts/icon: dependencies: express: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ded8f53e08..e40c4b1fac 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,5 @@ packages: - projects/app - projects/marketplace - projects/mcp_server - - projects/sandbox - scripts/icon - sdk/* diff --git a/projects/app/.env.template b/projects/app/.env.template index 394c7d72cd..c94b752a05 100644 --- a/projects/app/.env.template +++ b/projects/app/.env.template @@ -24,8 +24,9 @@ HIDE_CHAT_COPYRIGHT_SETTING= # Plugin PLUGIN_BASE_URL=http://localhost:3003 PLUGIN_TOKEN=token -# code sandbox url +# Code sandbox server SANDBOX_URL=http://localhost:3002 +SANDBOX_TOKEN= # ai proxy api AIPROXY_API_ENDPOINT=https://localhost:3010 AIPROXY_API_TOKEN=aiproxy diff --git a/projects/app/src/components/common/Modal/UseGuideModal.tsx b/projects/app/src/components/common/Modal/UseGuideModal.tsx index 09f5a8cfdb..a309b28da0 100644 --- a/projects/app/src/components/common/Modal/UseGuideModal.tsx +++ b/projects/app/src/components/common/Modal/UseGuideModal.tsx @@ -2,7 +2,7 @@ import { Box, ModalBody, useDisclosure } from '@chakra-ui/react'; import Markdown from '@/components/Markdown'; import MyModal from '@fastgpt/web/components/common/MyModal'; import { getDocPath } from '@/web/common/system/doc'; -import React from 'react'; +import React, { useCallback } from 'react'; const UseGuideModal = ({ children, @@ -18,14 +18,14 @@ const UseGuideModal = ({ link?: string; }) => { const { isOpen, onOpen, onClose } = useDisclosure(); - const onClick = () => { + const onClick = useCallback(() => { if (link) { return window.open(getDocPath(link), '_blank'); } if (text) { return onOpen(); } - }; + }, [link, text, onOpen]); return ( <> diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx index f440ebba63..218c78d58d 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/ChatItem.tsx @@ -268,6 +268,15 @@ const ChatItem = ({ hasPlanCheck, ...props }: Props) => { } ]); } + } else if (groupedValues.length === 0) { + // 对于非最后一条的空 AI 消息,也补充一个空节点,避免消息"消失" + groupedValues.push([ + { + text: { + content: '' + } + } + ]); } return groupedValues; diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx index a920bad6d7..d4d6871ab3 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/index.tsx @@ -1171,7 +1171,6 @@ const ChatBox = ({ return result; }, [chatType, chatRecords, expandedDeletedGroups]); - //chat history const hasPlanCheck = lastInteractive?.type === 'agentPlanCheck' && !lastInteractive.params.confirmed; diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/api.ts b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/api.ts new file mode 100644 index 0000000000..a50653d27e --- /dev/null +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/api.ts @@ -0,0 +1,5 @@ +import { GET } from '@/web/common/api/request'; +import type { SanndboxPackagesResponse } from '@fastgpt/service/thirdProvider/codeSandbox'; + +export const getSandboxPackages = async () => + GET('/core/workflow/getSandboxPackages'); diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/index.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/index.tsx index d93741ac14..504bc1238c 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/index.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodeCode/index.tsx @@ -14,7 +14,6 @@ import RenderOutput from '../render/RenderOutput'; import CodeEditor from '@fastgpt/web/components/common/Textarea/CodeEditor'; import { Box, Button, Flex } from '@chakra-ui/react'; import { useConfirm } from '@fastgpt/web/hooks/useConfirm'; -import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; import { JS_TEMPLATE, PY_TEMPLATE, @@ -30,6 +29,9 @@ import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance'; import { WorkflowUtilsContext } from '../../../context/workflowUtilsContext'; import { WorkflowActionsContext } from '../../../context/workflowActionsContext'; import { WorkflowUIContext } from '../../../context/workflowUIContext'; +import { useRequest } from '@fastgpt/web/hooks/useRequest'; +import { getSandboxPackages } from './api'; +import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; const NodeCode = ({ data, selected }: NodeProps) => { const { t } = useTranslation(); @@ -51,6 +53,22 @@ const NodeCode = ({ data, selected }: NodeProps) => { content: t('workflow:code.Switch language confirm') }); + const { data: packages } = useRequest(getSandboxPackages, { + manual: false, + errorToast: '' + }); + + const packageText = useMemo(() => { + const packagesList = + codeType.value === SandboxCodeTypeEnum.js + ? packages?.js.join(', ') + : packages?.python.join(', '); + return t('workflow:code_allow_packages_func', { + modules: packagesList, + globals: packages?.builtinGlobals.join(', ') + }); + }, [packages, codeType.value]); + const CustomComponent = useMemo(() => { return { [NodeInputKeyEnum.code]: (item: FlowNodeInputItemType) => { @@ -88,9 +106,15 @@ const NodeCode = ({ data, selected }: NodeProps) => { })(); }} /> - {codeType.value === 'py' && ( - + + {!!packages && ( + + + {t('workflow:code_allow_packages')} + + )} + @@ -135,7 +159,7 @@ const NodeCode = ({ data, selected }: NodeProps) => { ); } }; - }, [codeType, nodeId, t, presentationMode, onChangeNode]); + }, [packageText, codeType, nodeId, t, presentationMode, onChangeNode]); const { isTool, commonInputs } = useMemoEnhance( () => splitToolInputs(inputs, nodeId), diff --git a/projects/app/src/pages/api/core/workflow/getSandboxPackages.ts b/projects/app/src/pages/api/core/workflow/getSandboxPackages.ts new file mode 100644 index 0000000000..2bcb060b57 --- /dev/null +++ b/projects/app/src/pages/api/core/workflow/getSandboxPackages.ts @@ -0,0 +1,16 @@ +import { NextAPI } from '@/service/middleware/entry'; +import { authCert } from '@fastgpt/service/support/permission/auth/common'; +import { codeSandbox } from '@fastgpt/service/thirdProvider/codeSandbox'; +import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next'; + +export type ResponseType = {}; + +async function handler( + req: ApiRequestProps, + res: ApiResponseType +): Promise { + await authCert({ req, authToken: true }); + return codeSandbox.getPackages(); +} + +export default NextAPI(handler); diff --git a/projects/app/src/service/common/system/health.ts b/projects/app/src/service/common/system/health.ts index 4be921c948..16c9028fd8 100644 --- a/projects/app/src/service/common/system/health.ts +++ b/projects/app/src/service/common/system/health.ts @@ -4,8 +4,8 @@ import { POST } from '@fastgpt/service/common/api/plusRequest'; import { getLogger, LogCategories } from '@fastgpt/service/common/logger'; import { S3Buckets } from '@fastgpt/service/common/s3/constants'; import { InitialErrorEnum } from '@fastgpt/service/common/system/constants'; -import { runCode } from '@fastgpt/service/core/workflow/dispatch/tools/codeSandbox'; import { loadModelProviders } from '@fastgpt/service/thirdProvider/fastgptPlugin/model'; +import { codeSandbox } from '@fastgpt/service/thirdProvider/codeSandbox'; export const instrumentationCheck = async () => { const logger = getLogger(LogCategories.SYSTEM); @@ -50,13 +50,11 @@ export const instrumentationCheck = async () => { } // sandbox try { - await runCode({ + await codeSandbox.runCode({ codeType: SandboxCodeTypeEnum.py, code: `def main(): print("Hello, World!") - return { - } -`, + return {}`, variables: {} }); } catch (error) { diff --git a/projects/sandbox/.env.template b/projects/sandbox/.env.template new file mode 100644 index 0000000000..03eefabd01 --- /dev/null +++ b/projects/sandbox/.env.template @@ -0,0 +1,33 @@ +# ===== Server ===== +# Port the sandbox server listens on +SANDBOX_PORT=3000 +# Auth token for API requests (empty = no auth) +SANDBOX_TOKEN= + +# ===== Resource Limits ===== +# Execution timeout per request (ms) +SANDBOX_MAX_TIMEOUT=60000 +# Maximum allowed memory per user code execution (MB) +# Note: System automatically adds 50MB for runtime overhead +# Actual process limit = SANDBOX_MAX_MEMORY_MB + 50MB +SANDBOX_MAX_MEMORY_MB=256 + +# ===== Process Pool ===== +# Number of pre-warmed worker processes (JS + Python) +SANDBOX_POOL_SIZE=20 + +# ===== Network Request Limits ===== +# Maximum number of HTTP requests per execution +SANDBOX_REQUEST_MAX_COUNT=30 +# Timeout for each outbound HTTP request (ms) +SANDBOX_REQUEST_TIMEOUT=60000 +# Maximum response body size for outbound requests +SANDBOX_REQUEST_MAX_RESPONSE_MB=10 +# Maximum request body size for outbound requests (MB) +SANDBOX_REQUEST_MAX_BODY_MB=5 + +# ===== Module Control ===== +# JS allowed modules whitelist (comma-separated) +SANDBOX_JS_ALLOWED_MODULES=lodash,dayjs,moment,uuid,crypto-js,qs,url,querystring +# Python allowed modules whitelist (comma-separated) +SANDBOX_PYTHON_ALLOWED_MODULES=math,cmath,decimal,fractions,random,statistics,collections,array,heapq,bisect,queue,copy,itertools,functools,operator,string,re,difflib,textwrap,unicodedata,codecs,datetime,time,calendar,_strptime,json,csv,base64,binascii,struct,hashlib,hmac,secrets,uuid,typing,abc,enum,dataclasses,contextlib,pprint,weakref,numpy,pandas,matplotlib diff --git a/projects/sandbox/.eslintrc.js b/projects/sandbox/.eslintrc.js deleted file mode 100644 index 305e7efeaf..0000000000 --- a/projects/sandbox/.eslintrc.js +++ /dev/null @@ -1,25 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - parserOptions: { - project: 'tsconfig.json', - tsconfigRootDir: __dirname, - sourceType: 'module' - }, - plugins: ['@typescript-eslint/eslint-plugin'], - extends: ['plugin:@typescript-eslint/recommended'], - root: true, - env: { - node: true, - jest: true - }, - ignorePatterns: ['.eslintrc.js'], - rules: { - '@typescript-eslint/interface-name-prefix': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-vars': 'warn', - '@typescript-eslint/ban-ts-comment': 'off', - '@typescript-eslint/no-var-requires': 'off' - } -}; diff --git a/projects/sandbox/Dockerfile b/projects/sandbox/Dockerfile index 8915505fc8..84546ed3d0 100644 --- a/projects/sandbox/Dockerfile +++ b/projects/sandbox/Dockerfile @@ -1,73 +1,37 @@ -# --------- install dependence ----------- -FROM python:3.11-alpine AS python_base -# 安装make和g++以及libseccomp开发包 -RUN apk add --no-cache make g++ tar wget gperf automake libtool linux-headers libseccomp-dev - +# ===== Bun 构建层 ===== +FROM oven/bun:1-alpine AS builder WORKDIR /app -COPY projects/sandbox/requirements.txt /app/requirements.txt - -# 先安装Cython和其他Python依赖 -RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple Cython && \ - pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple -r /app/requirements.txt - -# 下载、编译并安装libseccomp及其Python绑定 -ENV VERSION_RELEASE=2.5.5 -RUN wget https://github.com/seccomp/libseccomp/releases/download/v2.5.5/libseccomp-2.5.5.tar.gz && \ - tar -zxvf libseccomp-2.5.5.tar.gz && \ - cd libseccomp-2.5.5 && \ - ./configure --prefix=/usr && \ - make && \ - make install && \ - cd src/python && \ - python setup.py install && \ - cd /app && \ - rm -rf libseccomp-2.5.5 libseccomp-2.5.5.tar.gz - - -FROM node:20.14.0-alpine AS install +COPY projects/sandbox/package.json projects/sandbox/bun.lock ./ +RUN bun install --frozen-lockfile +COPY projects/sandbox/src ./src +COPY projects/sandbox/tsconfig.json ./ +# ===== 运行层 ===== +FROM oven/bun:1-alpine AS runner WORKDIR /app -ARG proxy -RUN [ -z "$proxy" ] || sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories -RUN apk add --no-cache make g++ python3 +# 安装 Python、依赖包及工具 +# - util-linux: 提供 prlimit 命令(内存限制) +RUN apk add --no-cache python3 py3-pip libffi util-linux && \ + apk add --no-cache --virtual .build-deps gcc g++ musl-dev python3-dev libffi-dev +COPY projects/sandbox/requirements.txt /tmp/requirements.txt +RUN pip3 install --no-cache-dir --break-system-packages -r /tmp/requirements.txt && \ + rm /tmp/requirements.txt && \ + apk del .build-deps -# copy py3.11 -COPY --from=python_base /usr/local /usr/local +# 复制 node_modules 和源码 +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/src ./src +COPY --from=builder /app/package.json ./ -RUN npm install -g pnpm@9.4.0 -RUN [ -z "$proxy" ] || pnpm config set registry https://registry.npmmirror.com - -COPY pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY ./projects/sandbox/package.json ./projects/sandbox/package.json - -RUN [ -f pnpm-lock.yaml ] || (echo "Lockfile not found." && exit 1) - -RUN pnpm i - -# --------- builder ----------- -FROM node:20.14.0-alpine AS builder - -WORKDIR /app - -COPY package.json pnpm-workspace.yaml /app/ -COPY --from=install /app/node_modules /app/node_modules -COPY ./projects/sandbox /app/projects/sandbox -COPY --from=install /app/projects/sandbox /app/projects/sandbox - -RUN npm install -g pnpm@9.4.0 -RUN pnpm --filter=sandbox build - -# --------- runner ----------- -FROM node:20.14.0-alpine AS runner -WORKDIR /app - -RUN apk add --no-cache libffi libffi-dev strace bash -COPY --from=python_base /usr/local /usr/local -COPY --from=builder /app/node_modules /app/node_modules -COPY --from=builder /app/projects/sandbox /app/projects/sandbox +# 创建非 root 用户运行沙箱 +RUN addgroup -S sandbox && adduser -S sandbox -G sandbox && \ + chown -R sandbox:sandbox /app +USER sandbox ENV NODE_ENV=production -ENV PATH="/usr/local/bin:${PATH}" +ENV SANDBOX_PORT=3000 -CMD ["node", "--no-node-snapshot", "projects/sandbox/dist/main.js"] +EXPOSE 3000 + +CMD ["bun", "run", "src/index.ts"] diff --git a/projects/sandbox/README.md b/projects/sandbox/README.md index f5aa86c5dc..dcdaf0fe57 100644 --- a/projects/sandbox/README.md +++ b/projects/sandbox/README.md @@ -1,73 +1,312 @@ -

- Nest Logo -

+# FastGPT Code Sandbox -[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 -[circleci-url]: https://circleci.com/gh/nestjs/nest +基于 Bun + Hono 的代码执行沙盒,支持 JS 和 Python。采用进程池架构,预热长驻 worker 进程,通过 stdin/stdout JSON 协议通信,消除每次请求的进程启动开销。 -

A progressive Node.js framework for building efficient and scalable server-side applications.

-

-NPM Version -Package License -NPM Downloads -CircleCI -Coverage -Discord -Backers on Open Collective -Sponsors on Open Collective - - Support us - -

- +## 架构 -## Description - -[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. - -## Installation - -```bash -$ pnpm install +``` +HTTP Request → Hono Server → Process Pool → Worker (long-lived) → Result + ↓ + ┌──────────────┐ + │ JS Workers │ bun run worker.ts (×N) + │ Py Workers │ python3 worker.py (×N) + └──────────────┘ + stdin: JSON task → stdout: JSON result ``` -## Running the app +- **进程池**:启动时预热 N 个 worker 进程(默认 20),请求到达时直接分配空闲 worker,执行完归还池中 +- **JS 执行**:Bun worker 进程 + 安全 shim(禁用 Bun API、冻结 Function 构造器、require 白名单) +- **Python 执行**:python3 worker 进程 + `__import__` 拦截 + resource 资源限制 +- **网络请求**:统一通过 `SystemHelper.httpRequest()` / `system_helper.http_request()` 收口,内置 SSRF 防护 +- **并发控制**:请求数超过池大小时自动排队,worker 崩溃自动重启补充 + +## 性能 + +进程池 vs 旧版 spawn-per-request 对比(SANDBOX_POOL_SIZE=20): + +| 场景 | 旧版 QPS / P50 | 进程池 QPS / P50 | 提升 | +|------|----------------|------------------|------| +| JS 简单函数 (c50) | 22 / 1,938ms | 1,414 / 7ms | **64x** | +| JS IO 500ms (c50) | 22 / 2,107ms | 38 / 1,005ms | 1.7x | +| JS 高 CPU (c10) | 9 / 1,079ms | 12 / 796ms | 1.3x | +| JS 高内存 (c10) | — | 13 / 787ms | — | +| Python 简单函数 (c50) | 14.7 / 2,897ms | 4,247 / 4ms | **289x** | +| Python IO 500ms (c50) | 14.2 / 3,066ms | 38 / 1,003ms | 2.7x | +| Python 高 CPU (c10) | 3.1 / 2,845ms | 4 / 2,191ms | 1.3x | +| Python 高内存 (c10) | — | 11 / 893ms | — | + +资源占用(20+20 workers):空闲 ~1.5GB RSS,压测峰值 ~2GB RSS。 + +## 快速开始 ```bash -# development -$ pnpm run start +# 安装依赖 +bun install -# watch mode -$ pnpm run start:dev +# 开发运行 +bun run src/index.ts -# production mode -$ pnpm run start:prod +# 运行测试 +bun run test ``` -## Test +## Docker ```bash -# unit tests -$ pnpm run test +# 构建 +docker build -f projects/sandbox/Dockerfile -t fastgpt-sandbox . -# e2e tests -$ pnpm run test:e2e - -# test coverage -$ pnpm run test:cov +# 运行 +docker run -p 3000:3000 \ + -e SANDBOX_TOKEN=your-secret-token \ + -e SANDBOX_POOL_SIZE=20 \ + fastgpt-sandbox ``` -## Support +## API -Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). +### `POST /sandbox/js` -## Stay in touch +执行 JavaScript 代码。 -- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) -- Website - [https://nestjs.com](https://nestjs.com/) -- Twitter - [@nestframework](https://twitter.com/nestframework) +```json +{ + "code": "async function main(variables) {\n return { result: variables.a + variables.b }\n}", + "variables": { "a": 1, "b": 2 } +} +``` -## License +### `POST /sandbox/python` -Nest is [MIT licensed](LICENSE). +执行 Python 代码。 + +```json +{ + "code": "def main(variables):\n return {'result': variables['a'] + variables['b']}", + "variables": { "a": 1, "b": 2 } +} +``` + +### `GET /health` + +健康检查,返回进程池状态。 + +```json +{ + "status": "ok", + "version": "5.0.0", + "jsPool": { "total": 20, "idle": 18, "busy": 2, "queued": 0 }, + "pythonPool": { "total": 20, "idle": 20, "busy": 0, "queued": 0 } +} +``` + +### 响应格式 + +成功: + +```json +{ + "success": true, + "data": { + "codeReturn": { "result": 3 }, + "log": "console.log 输出内容" + } +} +``` + +失败: + +```json +{ + "success": false, + "message": "错误信息" +} +``` + +## 环境变量 + +### 服务配置 + +| 变量 | 说明 | 默认值 | +|------|------|--------| +| `SANDBOX_PORT` | 服务端口 | `3000` | +| `SANDBOX_TOKEN` | Bearer Token 认证密钥 | 空(不鉴权) | + +### 进程池 + +| 变量 | 说明 | 默认值 | +|------|------|--------| +| `SANDBOX_POOL_SIZE` | 每种语言的 worker 进程数 | `20` | + +### 资源限制 + +| 变量 | 说明 | 默认值 | +|------|------|--------| +| `SANDBOX_MAX_TIMEOUT` | 超时上限(ms),请求不可超过此值 | `60000` | +| `SANDBOX_MAX_MEMORY_MB` | 内存上限(MB) | `256` | + +### 网络请求限制 + +| 变量 | 说明 | 默认值 | +|------|------|--------| +| `SANDBOX_REQUEST_MAX_COUNT` | 单次执行最大 HTTP 请求数 | `30` | +| `SANDBOX_REQUEST_TIMEOUT` | 单次 HTTP 请求超时(ms) | `60000` | +| `SANDBOX_REQUEST_MAX_RESPONSE_MB` | 最大响应体大小(MB) | `10` | +| `SANDBOX_REQUEST_MAX_BODY_MB` | 最大请求体大小(MB) | `5` | + +## 项目结构 + +``` +src/ +├── index.ts # 入口:Hono 服务 + 进程池初始化 +├── env.ts # 环境变量校验(zod) +├── config.ts # 配置导出 +├── types.ts # 类型定义 +├── pool/ +│ ├── process-pool.ts # JS 进程池管理 +│ ├── python-process-pool.ts # Python 进程池管理 +│ ├── worker.ts # JS worker(长驻进程,含安全 shim) +│ └── worker.py # Python worker(长驻进程,含安全沙箱) +└── utils/ + └── semaphore.ts # 信号量(通用并发控制) + +test/ +├── unit/ # 单元测试(进程池、信号量) +├── integration/ # 集成测试(API 路由) +├── boundary/ # 边界测试(超时、内存限制) +├── security/ # 安全测试(沙箱逃逸防护) +├── compat/ # 兼容性测试(旧版代码格式) +├── examples/ # 示例测试(常用包) +└── benchmark/ # 压测脚本 +``` + +## 添加 JS 包 + +沙盒内的 JS 代码通过 `require()` 加载包,但仅允许白名单内的包。 + +### 当前白名单 + +`lodash`、`dayjs`、`moment`、`uuid`、`crypto-js`、`qs`、`url`、`querystring` + +### 添加新包步骤 + +1. **安装包**: + +```bash +cd projects/sandbox +bun add +``` + +2. **加入白名单**(环境变量 `SANDBOX_JS_ALLOWED_MODULES`): + +在逗号分隔列表中添加包名: + +```bash +SANDBOX_JS_ALLOWED_MODULES=lodash,dayjs,moment,uuid,crypto-js,qs,url,querystring,your-new-package +``` + +3. **重新构建 Docker 镜像**。 + +### 注意事项 + +- 只添加纯计算类的包,不要添加有网络/文件系统/子进程能力的包 +- 包会被打入 Docker 镜像,注意体积 +- 网络请求统一走 `SystemHelper.httpRequest()`,不要放行 `axios`、`node-fetch` 等网络库 + +## 添加 Python 包 + +### 当前预装包 + +`numpy`、`pandas`(通过 `requirements.txt` 安装) + +### 添加新包步骤 + +1. **编辑 `requirements.txt`**: + +``` +numpy +pandas +your-new-package +``` + +2. **加入白名单**(环境变量 `SANDBOX_PYTHON_ALLOWED_MODULES`): + +在逗号分隔列表中添加包名。如果新包依赖了黑名单中的模块(如 `os`),标准库路径的间接导入会自动放行,无需额外配置。 + +3. **重新构建 Docker 镜像**。 + +### 注意事项 + +- Python 的模块黑名单通过 `__import__` 拦截实现,只拦截用户代码的直接 import +- 标准库和第三方包的内部间接 import 不受影响 +- 危险模块(`os`、`sys`、`subprocess`、`socket` 等)始终被拦截 + +## 安全机制 + +### JS + +- `require()` 白名单,非白名单模块直接拒绝 +- `Bun.spawn`、`Bun.write`、`Bun.serve` 等 API 禁用 +- `Function` 构造器冻结,阻止 `constructor.constructor` 逃逸 +- `process.env` 清理,仅保留必要变量 +- `fetch`、`XMLHttpRequest`、`WebSocket` 禁用 + +### Python + +- `__import__` 黑名单拦截:用户代码无法 import 危险模块(`os`、`sys`、`subprocess` 等) +- `exec()`/`eval()` 内的 import 同样被拦截(基于调用栈帧检测) +- `builtins.__import__` 通过代理对象保护,用户无法覆盖 +- `signal.SIGALRM` 超时保护 + +### 网络 + +- 所有网络请求通过 `httpRequest()` 收口 +- 内网 IP 黑名单:`10.0.0.0/8`、`172.16.0.0/12`、`192.168.0.0/16`、`127.0.0.0/8`、`169.254.0.0/16` +- 仅允许 `http:` / `https:` 协议 +- 单次执行请求数、响应体大小、超时均有限制 + +## 内置函数 + +### JS(全局可用) + +| 函数 | 说明 | +|------|------| +| `SystemHelper.httpRequest(url, opts?)` | HTTP 请求(opts: `{method, headers, body, timeout}`) | + +### Python(全局可用) + +| 函数 | 说明 | +|------|------| +| `SystemHelper.httpRequest(url, opts?)` | HTTP 请求(opts: `{method, headers, body, timeout}`) | + +## 测试 + +```bash +# 全部测试(332 cases) +bun run test + +# 单个文件 +bunx vitest run test/security/security.test.ts + +# 带详细输出 +bunx vitest run --reporter=verbose + +# 压测(需先启动服务) +bash test/benchmark/bench-sandbox.sh +bash test/benchmark/bench-sandbox-python.sh +``` + +测试配置:串行执行(`fileParallelism: false`),池大小 1(避免资源竞争)。 + +测试覆盖维度: + +| 分类 | 文件数 | 用例数 | 说明 | +|------|--------|--------|------| +| 单元测试 | 4 | 43 | 进程池生命周期/恢复/健康检查、Semaphore 并发控制 | +| 集成测试 | 2 | 53 | HTTP API 路由、JS/Python 功能验证 | +| 安全测试 | 1 | 102 | 模块拦截、逃逸攻击、SSRF 防护、注入攻击 | +| 边界测试 | 1 | 58 | 空输入、超时、大数据、类型边界 | +| 兼容性测试 | 2 | 39 | 旧版 JS/Python 代码格式兼容 | +| 示例测试 | 1 | 31 | 常用场景和第三方包 | + +详细测试报告见 [`test/README.md`](test/README.md)。 diff --git a/projects/sandbox/bun.lock b/projects/sandbox/bun.lock new file mode 100644 index 0000000000..947877cd3a --- /dev/null +++ b/projects/sandbox/bun.lock @@ -0,0 +1,453 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "sandbox", + "dependencies": { + "axios": "^1.7.9", + "crypto-js": "^4.2.0", + "dayjs": "^1.11.13", + "dotenv": "^17.3.1", + "hono": "^4.7.6", + "lodash": "^4.17.21", + "moment": "^2.30.1", + "qs": "^6.13.1", + "tiktoken": "1.0.17", + "uuid": "^9.0.1", + "zod": "^4.3.6", + }, + "devDependencies": { + "@types/bun": "^1.2.4", + "@types/node": "^20.14.2", + "@vitest/coverage-v8": "^3.0.9", + "typescript": "^5.7.3", + "vitest": "^3.0.9", + }, + }, + }, + "packages": { + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "https://registry.npmmirror.com/@ampproject/remapping/-/remapping-2.3.0.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/parser": ["@babel/parser@7.29.0", "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@babel/types": ["@babel/types@7.29.0", "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "https://registry.npmmirror.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "https://registry.npmmirror.com/@istanbuljs/schema/-/schema-0.1.3.tgz", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="], + + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@20.19.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="], + + "@vitest/coverage-v8": ["@vitest/coverage-v8@3.2.4", "https://registry.npmmirror.com/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", "ast-v8-to-istanbul": "^0.3.3", "debug": "^4.4.1", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, "peerDependencies": { "@vitest/browser": "3.2.4", "vitest": "3.2.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ=="], + + "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + + "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + + "@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="], + + "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + + "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + + "ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "https://registry.npmmirror.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], + + "balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "brace-expansion": ["brace-expansion@5.0.3", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.3.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], + + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + + "color-convert": ["color-convert@2.0.1", "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="], + + "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "emoji-regex": ["emoji-regex@9.2.2", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + + "foreground-child": ["foreground-child@3.3.1", "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "glob": ["glob@10.5.0", "https://registry.npmmirror.com/glob/-/glob-10.5.0.tgz", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-flag": ["has-flag@4.0.0", "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="], + + "html-escaper": ["html-escaper@2.0.2", "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "https://registry.npmmirror.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "https://registry.npmmirror.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-lib-source-maps": ["istanbul-lib-source-maps@5.0.6", "https://registry.npmmirror.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" } }, "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "https://registry.npmmirror.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + + "jackspeak": ["jackspeak@3.4.3", "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "js-tokens": ["js-tokens@10.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-10.0.0.tgz", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + + "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "lru-cache": ["lru-cache@10.4.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "magicast": ["magicast@0.3.5", "https://registry.npmmirror.com/magicast/-/magicast-0.3.5.tgz", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="], + + "make-dir": ["make-dir@4.0.0", "https://registry.npmmirror.com/make-dir/-/make-dir-4.0.0.tgz", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "minimatch": ["minimatch@10.2.4", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.4.tgz", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "minipass": ["minipass@7.1.3", "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@1.11.1", "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="], + + "rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="], + + "semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "signal-exit": ["signal-exit@4.1.0", "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "string-width": ["string-width@5.1.2", "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "string-width-cjs": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@7.2.0", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + + "supports-color": ["supports-color@7.2.0", "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "test-exclude": ["test-exclude@7.0.2", "https://registry.npmmirror.com/test-exclude/-/test-exclude-7.0.2.tgz", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" } }, "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw=="], + + "tiktoken": ["tiktoken@1.0.17", "", {}, "sha512-UuFHqpy/DxOfNiC3otsqbx3oS6jr5uKdQhB/CvDEroZQbVHt+qAK+4JbIooabUWKU9g6PpsFylNu9Wcg4MxSGA=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + + "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], + + "which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "wrap-ansi": ["wrap-ansi@8.1.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "glob/minimatch": ["minimatch@9.0.9", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + } +} diff --git a/projects/sandbox/nest-cli.json b/projects/sandbox/nest-cli.json deleted file mode 100644 index e8552c298d..0000000000 --- a/projects/sandbox/nest-cli.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true, - "plugins": ["@nestjs/swagger"] - } -} diff --git a/projects/sandbox/package.json b/projects/sandbox/package.json index 68eb1ff894..76aa527142 100644 --- a/projects/sandbox/package.json +++ b/projects/sandbox/package.json @@ -1,71 +1,34 @@ { "name": "sandbox", - "version": "4.8.3", - "description": "", + "version": "5.0.0", + "description": "FastGPT Code Sandbox - Bun + Hono + 统一子进程模型", "author": "", "private": true, "license": "UNLICENSED", "scripts": { - "build": "nest build", - "start": "nest start", - "dev": "NODE_OPTIONS='--no-node-snapshot' nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { - "@fastify/static": "^7.0.4", - "@nestjs/common": "^10.4.16", - "@nestjs/core": "^10.0.0", - "@nestjs/platform-fastify": "^10.3.8", - "@nestjs/swagger": "^7.3.1", - "dayjs": "^1.11.7", - "fastify": "^4.29.1", - "isolated-vm": "^4.7.2", - "node-gyp": "^10.1.0", - "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.1", - "tiktoken": "1.0.17" + "axios": "^1.7.9", + "crypto-js": "^4.2.0", + "dayjs": "^1.11.13", + "dotenv": "^17.3.1", + "hono": "^4.7.6", + "lodash": "^4.17.21", + "moment": "^2.30.1", + "qs": "^6.13.1", + "tiktoken": "1.0.17", + "uuid": "^9.0.1", + "zod": "^4.3.6" }, "devDependencies": { - "@nestjs/cli": "^10.0.0", - "@nestjs/schematics": "^10.0.0", - "@nestjs/testing": "^10.0.0", - "@types/jest": "^29.5.2", + "@types/bun": "^1.2.4", "@types/node": "^20.14.2", - "@types/supertest": "^6.0.0", - "@typescript-eslint/eslint-plugin": "^6.21.0", - "@typescript-eslint/parser": "^6.21.0", - "eslint": "8.56.0", - "jest": "^29.5.0", - "source-map-support": "^0.5.21", - "supertest": "^6.3.3", - "ts-jest": "^29.1.0", - "ts-loader": "^9.4.3", - "ts-node": "^10.9.1", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.1.3" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" + "vitest": "^3.0.9", + "@vitest/coverage-v8": "^3.0.9", + "typescript": "^5.7.3" } } diff --git a/projects/sandbox/requirements.txt b/projects/sandbox/requirements.txt index 64d4bfd8c9..fb0f9d36c0 100644 --- a/projects/sandbox/requirements.txt +++ b/projects/sandbox/requirements.txt @@ -1,2 +1,3 @@ numpy -pandas \ No newline at end of file +pandas +matplotlib \ No newline at end of file diff --git a/projects/sandbox/src/app.module.ts b/projects/sandbox/src/app.module.ts deleted file mode 100644 index fe0556c519..0000000000 --- a/projects/sandbox/src/app.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SandboxController } from './sandbox/sandbox.controller'; -import { SandboxService } from './sandbox/sandbox.service'; - -@Module({ - imports: [], - controllers: [SandboxController], - providers: [SandboxService] -}) -export class AppModule {} diff --git a/projects/sandbox/src/config.ts b/projects/sandbox/src/config.ts new file mode 100644 index 0000000000..35be39ff37 --- /dev/null +++ b/projects/sandbox/src/config.ts @@ -0,0 +1,8 @@ +/** + * 配置入口 - 从 env.ts 重新导出 + * + * 保持向后兼容:其他模块继续 import { config } from './config' + */ +import { env } from './env'; + +export const config = env; diff --git a/projects/sandbox/src/env.ts b/projects/sandbox/src/env.ts new file mode 100644 index 0000000000..4dc75853e1 --- /dev/null +++ b/projects/sandbox/src/env.ts @@ -0,0 +1,101 @@ +/** + * 环境变量加载与校验 + * + * 使用 dotenv 加载 .env 文件,zod 做类型转换和校验。 + */ +import dotenv from 'dotenv'; +import { z } from 'zod'; + +dotenv.config(); + +/** coerce 数字,带默认值 */ +const int = (defaultValue: number) => z.coerce.number().int().default(defaultValue); + +/** 字符串,带默认值 */ +const str = (defaultValue: string) => z.string().default(defaultValue); + +const envSchema = z.object({ + // ===== 服务 ===== + SANDBOX_PORT: int(3000), + /** Bearer token,仅允许 ASCII 可打印字符(RFC 6750) */ + SANDBOX_TOKEN: z + .string() + .default('') + .refine((v) => v === '' || /^[\x21-\x7E]+$/.test(v), { + message: + 'SANDBOX_TOKEN contains invalid characters. Only ASCII printable characters (no spaces) are allowed.' + }), + + // ===== 进程池 ===== + /** 进程池大小(预热 worker 数量) */ + SANDBOX_POOL_SIZE: int(20).pipe(z.number().min(1).max(100)), + + // ===== 资源限制 ===== + SANDBOX_MAX_TIMEOUT: int(60000).pipe(z.number().min(1000).max(600000)), + SANDBOX_MAX_MEMORY_MB: int(256).pipe(z.number().min(32).max(4096)), + + // ===== 网络请求限制 ===== + SANDBOX_REQUEST_MAX_COUNT: int(30).pipe(z.number().min(1).max(1000)), + SANDBOX_REQUEST_TIMEOUT: int(60000).pipe(z.number().min(1000).max(300000)), + SANDBOX_REQUEST_MAX_RESPONSE_MB: int(10).pipe(z.number().min(1).max(100)), + SANDBOX_REQUEST_MAX_BODY_MB: int(5).pipe(z.number().min(1).max(100)), + + // ===== 模块控制 ===== + /** JS 可用模块白名单,逗号分隔 */ + SANDBOX_JS_ALLOWED_MODULES: str('lodash,dayjs,moment,uuid,crypto-js,qs,url,querystring'), + /** Python 可用模块白名单,逗号分隔 */ + SANDBOX_PYTHON_ALLOWED_MODULES: str( + 'math,cmath,decimal,fractions,random,statistics,' + + 'collections,array,heapq,bisect,queue,copy,' + + 'itertools,functools,operator,' + + 'string,re,difflib,textwrap,unicodedata,codecs,' + + 'datetime,time,calendar,_strptime,' + + 'json,csv,base64,binascii,struct,' + + 'hashlib,hmac,secrets,uuid,' + + 'typing,abc,enum,dataclasses,contextlib,' + + 'pprint,' + + 'numpy,pandas,matplotlib' + ) +}); + +const parsed = envSchema.safeParse(process.env); + +if (!parsed.success) { + console.error('❌ Invalid environment variables:'); + console.error(parsed.error.format()); + process.exit(1); +} + +const e = parsed.data; + +/** 类型安全的配置对象,字段名与代码风格一致 */ +export const env = { + // 服务 + port: e.SANDBOX_PORT, + token: e.SANDBOX_TOKEN, + + // 资源限制 + maxTimeoutMs: e.SANDBOX_MAX_TIMEOUT, + maxMemoryMB: e.SANDBOX_MAX_MEMORY_MB, + /** 运行时内存开销(运行时 + 沙箱代码) */ + RUNTIME_MEMORY_OVERHEAD_MB: 50, + + // 进程池 + poolSize: e.SANDBOX_POOL_SIZE, + + // 网络请求限制 + maxRequests: e.SANDBOX_REQUEST_MAX_COUNT, + requestTimeoutMs: e.SANDBOX_REQUEST_TIMEOUT, + maxResponseSize: e.SANDBOX_REQUEST_MAX_RESPONSE_MB, + maxRequestBodySize: e.SANDBOX_REQUEST_MAX_BODY_MB, + + // 模块控制 + jsAllowedModules: e.SANDBOX_JS_ALLOWED_MODULES.split(',') + .map((s) => s.trim()) + .filter(Boolean), + pythonAllowedModules: e.SANDBOX_PYTHON_ALLOWED_MODULES.split(',') + .map((s) => s.trim()) + .filter(Boolean) +} as const; + +export type Env = typeof env; diff --git a/projects/sandbox/src/http-exception.filter.ts b/projects/sandbox/src/http-exception.filter.ts deleted file mode 100644 index ed56db8e79..0000000000 --- a/projects/sandbox/src/http-exception.filter.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ExceptionFilter, Catch, ArgumentsHost } from '@nestjs/common'; -import { FastifyRequest, FastifyReply } from 'fastify'; -import { getErrText } from './utils'; - -@Catch() -export class HttpExceptionFilter implements ExceptionFilter { - catch(error: any, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - - response.status(500).send({ - success: false, - time: new Date(), - message: getErrText(error) - }); - } -} diff --git a/projects/sandbox/src/index.ts b/projects/sandbox/src/index.ts new file mode 100644 index 0000000000..acead6ebd1 --- /dev/null +++ b/projects/sandbox/src/index.ts @@ -0,0 +1,123 @@ +import './env'; // dotenv 最先加载 +import { Hono } from 'hono'; +import { bearerAuth } from 'hono/bearer-auth'; +import { z } from 'zod'; +import { config } from './config'; +import { ProcessPool } from './pool/process-pool'; +import { PythonProcessPool } from './pool/python-process-pool'; +import type { ExecuteOptions } from './types'; +import { getErrText } from './utils'; + +/** 请求体校验 schema */ +const executeSchema = z.object({ + code: z + .string() + .min(1) + .max(5 * 1024 * 1024), // 最大 5MB 代码 + variables: z.record(z.string(), z.any()).default({}) +}); + +const app = new Hono(); + +/** 进程池 */ +const jsPool = new ProcessPool(config.poolSize); +const pythonPool = new PythonProcessPool(config.poolSize); + +const poolReady = Promise.all([jsPool.init(), pythonPool.init()]) + .then(() => { + console.log(`Process pools ready: JS=${config.poolSize}, Python=${config.poolSize} workers`); + }) + .catch((err) => { + console.log('Failed to init process pool:', err.message); + process.exit(1); + }); + +/** 健康检查(不需要认证) */ +app.get('/health', (c) => { + const jsStats = jsPool.stats; + const pyStats = pythonPool.stats; + const isReady = jsStats.total > 0 && pyStats.total > 0; + return c.json({ status: isReady ? 'ok' : 'degraded' }, isReady ? 200 : 503); +}); + +/** 认证中间件:仅当配置了 token 时启用 */ +if (config.token) { + app.use('/sandbox/*', bearerAuth({ token: config.token })); +} else { + console.warn( + '⚠️ WARNING: SANDBOX_TOKEN is not set. API endpoints are unauthenticated. Set SANDBOX_TOKEN in production!' + ); +} + +/** JS 执行 */ +app.post('/sandbox/js', async (c) => { + try { + const raw = await c.req.json(); + const parsed = executeSchema.safeParse(raw); + if (!parsed.success) { + return c.json( + { + success: false, + message: `Invalid request: ${parsed.error.issues[0]?.message || 'validation failed'}` + }, + 400 + ); + } + const result = await jsPool.execute(parsed.data as ExecuteOptions); + return c.json(result); + } catch (err: any) { + console.log('JS sandbox error:', err); + return c.json({ + success: false, + message: getErrText(err) + }); + } +}); + +/** Python 执行 */ +app.post('/sandbox/python', async (c) => { + try { + const raw = await c.req.json(); + const parsed = executeSchema.safeParse(raw); + if (!parsed.success) { + return c.json( + { + success: false, + message: `Invalid request: ${parsed.error.issues[0]?.message || 'validation failed'}` + }, + 400 + ); + } + const result = await pythonPool.execute(parsed.data as ExecuteOptions); + return c.json(result); + } catch (err: any) { + console.log('Python sandbox error:', err); + return c.json({ + success: false, + message: getErrText(err) + }); + } +}); + +/** 查询可用模块 */ +app.get('/sandbox/modules', (c) => { + return c.json({ + success: true, + data: { + js: config.jsAllowedModules, + python: config.pythonAllowedModules, + builtinGlobals: ['SystemHelper.httpRequest'] + } + }); +}); + +/** 启动服务 */ +console.log(`Sandbox server starting on port ${config.port}...`); + +export default { + port: config.port, + fetch: app.fetch +}; + +/** 导出 app 和 poolReady 供测试使用 */ +export { app, poolReady }; diff --git a/projects/sandbox/src/main.ts b/projects/sandbox/src/main.ts deleted file mode 100644 index 258f44cf1f..0000000000 --- a/projects/sandbox/src/main.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'; -import { AppModule } from './app.module'; -import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; -import { HttpExceptionFilter } from './http-exception.filter'; -import { ResponseInterceptor } from './response'; - -async function bootstrap(port: number) { - const app = await NestFactory.create( - AppModule, - new FastifyAdapter({ - bodyLimit: 50 * 1048576 // 50MB - }) - ); - - // 使用全局异常过滤器 - app.useGlobalFilters(new HttpExceptionFilter()); - - app.useGlobalInterceptors(new ResponseInterceptor()); - - const config = new DocumentBuilder() - .setTitle('Cats example') - .setDescription('The cats API description') - .setVersion('1.0') - .addTag('cats') - .build(); - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api', app, document); - - try { - await app.listen(port, '0.0.0.0'); - console.log(`Application is running on: ${await app.getUrl()}`); - } catch (error) { - if ((error as any).code === 'EADDRINUSE') { - console.warn(`Port ${port} is already in use, trying next port...`); - await bootstrap(port + 1); - } else { - console.error(`Failed to start application: ${(error as Error).message}`); - process.exit(1); - } - } -} -bootstrap(3000); diff --git a/projects/sandbox/src/pool/base-process-pool.ts b/projects/sandbox/src/pool/base-process-pool.ts new file mode 100644 index 0000000000..bdcad0b5e6 --- /dev/null +++ b/projects/sandbox/src/pool/base-process-pool.ts @@ -0,0 +1,462 @@ +/** + * BaseProcessPool - 进程池基类 + * + * 预热 N 个长驻 worker 进程,通过 stdin/stdout 行协议通信。 + * JS / Python 进程池继承此类,仅需提供 spawn 命令和 init 配置。 + */ +import { spawn, type ChildProcess } from 'child_process'; +import { createInterface, type Interface } from 'readline'; +import { exec } from 'child_process'; +import { readFile } from 'fs/promises'; +import { promisify } from 'util'; +import { platform } from 'os'; +import { config } from '../config'; +import type { ExecuteOptions, ExecuteResult } from '../types'; + +const execAsync = promisify(exec); + +/** RSS 轮询间隔(毫秒) */ +const RSS_POLL_INTERVAL = 500; + +export type PoolWorker = { + proc: ChildProcess; + rl: Interface; + busy: boolean; + id: number; + stderrBuf: string[]; +}; + +export type ProcessPoolOptions = { + /** 日志前缀,如 "JS" / "Python" */ + name: string; + /** worker 脚本绝对路径 */ + workerScript: string; + /** 生成 spawn 命令 */ + spawnCommand: (script: string) => string; + /** init 消息中的模块白名单 */ + allowedModules: readonly string[]; +}; + +export abstract class BaseProcessPool { + protected workers: PoolWorker[] = []; + protected idleWorkers: PoolWorker[] = []; + protected waitQueue: { resolve: (w: PoolWorker) => void; reject: (e: Error) => void }[] = []; + protected nextId = 0; + protected poolSize: number; + protected ready = false; + protected healthCheckTimer?: ReturnType; + + protected static readonly HEALTH_CHECK_INTERVAL = 30_000; + protected static readonly HEALTH_CHECK_TIMEOUT = 5_000; + protected static readonly SPAWN_TIMEOUT = 120_000; + + constructor( + poolSize: number | undefined, + protected readonly options: ProcessPoolOptions + ) { + this.poolSize = poolSize ?? config.poolSize; + } + + /** 日志前缀 */ + protected get tag(): string { + return `${this.options.name}ProcessPool`; + } + + // ============================================================ + // 生命周期 + // ============================================================ + + async init(): Promise { + const promises: Promise[] = []; + for (let i = 0; i < this.poolSize; i++) { + promises.push(this.spawnWorker()); + } + await Promise.all(promises); + this.ready = true; + this.startHealthCheck(); + console.log(`${this.tag}: ${this.poolSize} workers preheated`); + } + + async shutdown(): Promise { + this.ready = false; + if (this.healthCheckTimer) { + clearInterval(this.healthCheckTimer); + this.healthCheckTimer = undefined; + } + for (const waiter of this.waitQueue) { + waiter.reject(new Error('Pool is shutting down')); + } + for (const worker of this.workers) { + worker.proc.kill('SIGTERM'); + } + this.workers = []; + this.idleWorkers = []; + this.waitQueue = []; + } + + get stats() { + return { + total: this.workers.length, + idle: this.idleWorkers.length, + busy: this.workers.filter((w) => w.busy).length, + queued: this.waitQueue.length, + poolSize: this.poolSize + }; + } + + // ============================================================ + // Spawn & Worker 管理 + // ============================================================ + + protected spawnWorker(): Promise { + return new Promise((resolve, reject) => { + const id = this.nextId++; + const cmd = this.options.spawnCommand(this.options.workerScript); + const proc = spawn('sh', ['-c', cmd], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { + PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin' + } + }); + + const rl = createInterface({ input: proc.stdout!, terminal: false }); + const worker: PoolWorker = { proc, rl, busy: false, id, stderrBuf: [] }; + + // 收集 stderr 用于调试(保留最近 20 行) + const stderrRl = createInterface({ input: proc.stderr!, terminal: false }); + stderrRl.on('line', (line: string) => { + worker.stderrBuf.push(line); + if (worker.stderrBuf.length > 20) worker.stderrBuf.shift(); + }); + + let settled = false; + const spawnTimer = setTimeout(() => { + if (settled) return; + settled = true; + rl.removeAllListeners('line'); + proc.kill('SIGKILL'); + const stderr = this.formatStderr(worker); + reject( + new Error( + `${this.tag}: worker ${id} init timeout after ${BaseProcessPool.SPAWN_TIMEOUT}ms${stderr}` + ) + ); + }, BaseProcessPool.SPAWN_TIMEOUT); + + const onFirstLine = (line: string) => { + if (settled) return; + settled = true; + clearTimeout(spawnTimer); + try { + const msg = JSON.parse(line); + if (msg.type === 'ready') { + this.workers.push(worker); + this.setupWorkerEvents(worker); + const waiter = this.waitQueue.shift(); + if (waiter) { + worker.busy = true; + waiter.resolve(worker); + } else { + this.idleWorkers.push(worker); + } + resolve(); + } else { + reject(new Error(`${this.tag}: worker ${id} init failed: ${line}`)); + } + } catch { + reject(new Error(`${this.tag}: worker ${id} invalid init response: ${line}`)); + } + }; + rl.once('line', onFirstLine); + + proc.on('error', (err) => { + if (settled) return; + settled = true; + clearTimeout(spawnTimer); + reject(new Error(`${this.tag}: worker ${id} spawn error: ${err.message}`)); + }); + + proc.on('exit', (code, signal) => { + if (settled) return; + settled = true; + clearTimeout(spawnTimer); + rl.removeAllListeners('line'); + const stderr = this.formatStderr(worker); + reject( + new Error( + `${this.tag}: worker ${id} exited during init (code: ${code}, signal: ${signal})${stderr}` + ) + ); + }); + + // 发送 init 消息 + proc.stdin!.write( + JSON.stringify({ + type: 'init', + allowedModules: this.options.allowedModules, + requestLimits: { + maxRequests: config.maxRequests, + timeoutMs: config.requestTimeoutMs, + maxResponseSize: config.maxResponseSize * 1024 * 1024, + maxRequestBodySize: config.maxRequestBodySize * 1024 * 1024 + } + }) + '\n' + ); + }); + } + + protected setupWorkerEvents(worker: PoolWorker): void { + worker.proc.on('exit', () => { + const removed = this.removeWorker(worker); + if (this.ready && removed) { + this.spawnWorker().catch((err) => { + console.error(`${this.tag}: failed to respawn worker ${worker.id}:`, err.message); + }); + } + }); + } + + protected removeWorker(worker: PoolWorker): boolean { + const idx = this.workers.indexOf(worker); + if (idx === -1) return false; + this.workers.splice(idx, 1); + this.idleWorkers = this.idleWorkers.filter((w) => w !== worker); + return true; + } + + // ============================================================ + // Acquire / Release + // ============================================================ + + protected acquire(): Promise { + const idle = this.idleWorkers.shift(); + if (idle) { + idle.busy = true; + return Promise.resolve(idle); + } + return new Promise((resolve, reject) => { + this.waitQueue.push({ resolve, reject }); + }); + } + + protected release(worker: PoolWorker): void { + worker.busy = false; + if (!this.workers.includes(worker)) return; + const waiter = this.waitQueue.shift(); + if (waiter) { + worker.busy = true; + waiter.resolve(worker); + } else { + this.idleWorkers.push(worker); + } + } + + // ============================================================ + // 执行 + // ============================================================ + + async execute(options: ExecuteOptions): Promise { + const { code, variables } = options; + + if (!code || typeof code !== 'string' || !code.trim()) { + return { success: false, message: 'Code cannot be empty' }; + } + + const timeoutMs = config.maxTimeoutMs; + const worker = await this.acquire(); + + try { + return await this.sendTask( + worker, + { code, variables: variables || {}, timeoutMs }, + timeoutMs + ); + } finally { + this.release(worker); + } + } + + protected sendTask( + worker: PoolWorker, + task: { code: string; variables: Record; timeoutMs: number }, + timeoutMs: number + ): Promise { + return new Promise((resolve) => { + let settled = false; + let timer: ReturnType; + let rssTimer: ReturnType | undefined; + + const settle = (result: ExecuteResult) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (rssTimer) clearInterval(rssTimer); + worker.rl.removeListener('line', onLine); + worker.proc.removeListener('exit', onExit); + resolve(result); + }; + + const onLine = (line: string) => { + try { + settle(JSON.parse(line)); + } catch { + settle({ success: false, message: 'Invalid worker response' }); + } + }; + + const onExit = (code: number | null, signal: string | null) => { + const stderr = this.formatStderr(worker); + settle({ + success: false, + message: `Worker crashed during execution (exit code: ${code}, signal: ${signal})${stderr}` + }); + }; + + // 超时 + timer = setTimeout(() => { + this.killAndRespawn(worker); + settle({ success: false, message: `Script execution timed out after ${timeoutMs}ms` }); + }, timeoutMs + 2000); + + // RSS 内存监控(任务执行期间轮询子进程物理内存) + if (config.maxMemoryMB > 0 && worker.proc.pid) { + const limitMB = config.maxMemoryMB + config.RUNTIME_MEMORY_OVERHEAD_MB; + rssTimer = setInterval(async () => { + if (settled) return; + const rss = await this.getWorkerRSSMB(worker.proc.pid!); + if (rss !== null && rss > limitMB) { + this.killAndRespawn(worker); + settle({ + success: false, + message: `Memory limit exceeded (RSS: ${Math.round(rss)}MB, limit: ${limitMB}MB)` + }); + } + }, RSS_POLL_INTERVAL); + } + + worker.rl.once('line', onLine); + worker.proc.once('exit', onExit); + + try { + worker.proc.stdin!.write(JSON.stringify(task) + '\n'); + } catch (err: any) { + settle({ success: false, message: `Worker communication error: ${err.message}` }); + } + }); + } + + // ============================================================ + // 健康检查 + // ============================================================ + + protected startHealthCheck(): void { + this.healthCheckTimer = setInterval(() => { + if (!this.ready) return; + const idleCopy = [...this.idleWorkers]; + for (const worker of idleCopy) { + this.pingWorker(worker); + } + }, BaseProcessPool.HEALTH_CHECK_INTERVAL); + if (this.healthCheckTimer.unref) this.healthCheckTimer.unref(); + } + + protected pingWorker(worker: PoolWorker): void { + if (worker.busy || !this.idleWorkers.includes(worker)) return; + + this.idleWorkers = this.idleWorkers.filter((w) => w !== worker); + + const replaceWorker = (reason: string) => { + console.warn(`${this.tag}: worker ${worker.id} ${reason}, replacing`); + this.killAndRespawn(worker); + }; + + const returnToIdle = () => { + if (!worker.busy && this.workers.includes(worker) && !this.idleWorkers.includes(worker)) { + const waiter = this.waitQueue.shift(); + if (waiter) { + worker.busy = true; + waiter.resolve(worker); + } else { + this.idleWorkers.push(worker); + } + } + }; + + const timer = setTimeout(() => { + worker.rl.removeListener('line', onPong); + replaceWorker('health check timeout (no pong)'); + }, BaseProcessPool.HEALTH_CHECK_TIMEOUT); + + const onPong = (line: string) => { + clearTimeout(timer); + try { + const msg = JSON.parse(line); + if (msg.type !== 'pong') { + replaceWorker('health check invalid response'); + } else { + returnToIdle(); + } + } catch { + replaceWorker('health check parse error'); + } + }; + + try { + if (!worker.proc.stdin!.writable) { + clearTimeout(timer); + replaceWorker('stdin not writable'); + return; + } + worker.rl.once('line', onPong); + worker.proc.stdin!.write(JSON.stringify({ type: 'ping' }) + '\n'); + } catch { + clearTimeout(timer); + replaceWorker('health check write error'); + } + } + + // ============================================================ + // 工具方法 + // ============================================================ + + /** + * 读取子进程的物理内存(RSS) + * @param pid 进程 ID + * @returns RSS(MB),失败返回 null + */ + protected async getWorkerRSSMB(pid: number): Promise { + try { + const plat = platform(); + if (plat === 'linux' || plat === 'freebsd') { + // Linux/BSD: 读取 /proc/{pid}/status 中的 VmRSS(单位 kB) + const status = await readFile(`/proc/${pid}/status`, 'utf-8'); + const match = status.match(/VmRSS:\s+(\d+)\s+kB/); + if (match) return parseInt(match[1], 10) / 1024; + } else { + // macOS/other: 使用 ps 命令获取 RSS(单位 kB) + const { stdout } = await execAsync(`ps -o rss= -p ${pid}`, { timeout: 2000 }); + const rssKB = parseInt(stdout.trim(), 10); + if (!isNaN(rssKB)) return rssKB / 1024; + } + } catch { + // 进程可能已退出,忽略错误 + } + return null; + } + + /** 从池中移除 worker,kill 进程,并在 ready 时 respawn */ + protected killAndRespawn(worker: PoolWorker): void { + this.removeWorker(worker); + worker.proc.kill('SIGKILL'); + if (this.ready) { + this.spawnWorker().catch((err) => { + console.error(`${this.tag}: failed to respawn worker ${worker.id}:`, err.message); + }); + } + } + + /** 格式化 stderr 缓冲区用于错误信息 */ + protected formatStderr(worker: PoolWorker): string { + return worker.stderrBuf.length > 0 ? ` | stderr: ${worker.stderrBuf.join('\n')}` : ''; + } +} diff --git a/projects/sandbox/src/pool/process-pool.ts b/projects/sandbox/src/pool/process-pool.ts new file mode 100644 index 0000000000..36c1757fc4 --- /dev/null +++ b/projects/sandbox/src/pool/process-pool.ts @@ -0,0 +1,24 @@ +/** + * ProcessPool - JS (Bun) 子进程池 + * + * 继承 BaseProcessPool,提供 Bun worker 的 spawn 配置。 + */ +import { join } from 'path'; +import { config } from '../config'; +import { BaseProcessPool } from './base-process-pool'; + +const WORKER_SCRIPT = join( + typeof import.meta.dir === 'string' ? import.meta.dir : new URL('.', import.meta.url).pathname, + 'worker.ts' +); + +export class ProcessPool extends BaseProcessPool { + constructor(poolSize?: number) { + super(poolSize, { + name: 'JS', + workerScript: WORKER_SCRIPT, + spawnCommand: (script) => `exec bun run ${script}`, + allowedModules: config.jsAllowedModules + }); + } +} diff --git a/projects/sandbox/src/pool/python-process-pool.ts b/projects/sandbox/src/pool/python-process-pool.ts new file mode 100644 index 0000000000..40400a20ac --- /dev/null +++ b/projects/sandbox/src/pool/python-process-pool.ts @@ -0,0 +1,24 @@ +/** + * PythonProcessPool - Python 子进程池 + * + * 继承 BaseProcessPool,提供 Python worker 的 spawn 配置。 + */ +import { join } from 'path'; +import { config } from '../config'; +import { BaseProcessPool } from './base-process-pool'; + +const WORKER_SCRIPT = join( + typeof import.meta.dir === 'string' ? import.meta.dir : new URL('.', import.meta.url).pathname, + 'worker.py' +); + +export class PythonProcessPool extends BaseProcessPool { + constructor(poolSize?: number) { + super(poolSize, { + name: 'Python', + workerScript: WORKER_SCRIPT, + spawnCommand: (script) => `exec python3 -u ${script}`, + allowedModules: config.pythonAllowedModules + }); + } +} diff --git a/projects/sandbox/src/pool/worker.py b/projects/sandbox/src/pool/worker.py new file mode 100644 index 0000000000..f46cb2985c --- /dev/null +++ b/projects/sandbox/src/pool/worker.py @@ -0,0 +1,638 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Python Worker 长驻进程 - 循环接收任务执行 + +协议: + 第 1 行 stdin: {"type":"init","allowedModules":["math","json",...]} + 后续每行 stdin: {"code":"...","variables":{},"timeoutMs":10000} + 每行 stdout: {"success":true,"data":{...}} 或 {"success":false,"message":"..."} +""" +import json +import sys +import copy as _copy +import hashlib as _hashlib +import hmac as _hmac +import base64 as _base64 +import urllib.parse as _urllib_parse +import urllib.request as _urllib_request +import socket as _socket +import ipaddress as _ipaddress +import time as _time +import math as _math +import inspect as _inspect_mod +import ast as _ast +import signal +import traceback as _tb +import sysconfig as _sysconfig +import builtins as _builtins +import types as _types +import encodings.idna as _encodings_idna # 预加载,避免 codec lazy load 被沙盒拦截 + +# stdlib 模块名集合,用于 _safe_import 快速放行 +_STDLIB_MODULES = sys.stdlib_module_names if hasattr(sys, 'stdlib_module_names') else frozenset() + +# 危险的 stdlib 模块,即使是 stdlib 也不允许用户代码直接 import +_DANGEROUS_STDLIB = frozenset({ + 'os', 'subprocess', 'shutil', 'pathlib', 'glob', 'tempfile', + 'multiprocessing', 'threading', 'concurrent', + 'ctypes', 'importlib', 'runpy', 'code', 'codeop', 'compileall', + 'socket', 'http', 'urllib', 'ftplib', 'smtplib', 'poplib', 'imaplib', + 'xmlrpc', 'socketserver', 'ssl', 'asyncio', 'selectors', 'select', + 'signal', 'resource', 'pty', 'termios', 'tty', 'fcntl', + 'mmap', 'dbm', 'sqlite3', 'shelve', + 'webbrowser', 'turtle', 'tkinter', 'idlelib', + 'venv', 'ensurepip', 'pip', 'site', + 'gc', 'sys', 'builtins', 'marshal', 'pickle', +}) + +# ===== 网络安全 ===== +_BLOCKED_CIDRS = [ + _ipaddress.ip_network('10.0.0.0/8'), + _ipaddress.ip_network('172.16.0.0/12'), + _ipaddress.ip_network('192.168.0.0/16'), + _ipaddress.ip_network('169.254.0.0/16'), + _ipaddress.ip_network('127.0.0.0/8'), + _ipaddress.ip_network('0.0.0.0/8'), + _ipaddress.ip_network('::1/128'), + _ipaddress.ip_network('fc00::/7'), + _ipaddress.ip_network('fe80::/10'), +] + +_REQUEST_LIMITS = { + 'max_requests': 30, + 'timeout': 60, + 'max_response_size': 10 * 1024 * 1024, + 'max_request_body_size': 5 * 1024 * 1024, + 'allowed_protocols': ['http:', 'https:'] +} + + +def _init_request_limits(limits): + """从 init 消息更新请求限制""" + if not limits: + return + if 'maxRequests' in limits: + _REQUEST_LIMITS['max_requests'] = limits['maxRequests'] + if 'timeoutMs' in limits: + _REQUEST_LIMITS['timeout'] = max(1, limits['timeoutMs'] // 1000) + if 'maxResponseSize' in limits: + _REQUEST_LIMITS['max_response_size'] = limits['maxResponseSize'] + if 'maxRequestBodySize' in limits: + _REQUEST_LIMITS['max_request_body_size'] = limits['maxRequestBodySize'] + + +def _is_blocked_ip(ip_str): + try: + addr = _ipaddress.ip_address(ip_str) + for net in _BLOCKED_CIDRS: + if addr in net: + return True + except ValueError: + return True + return False + + +# ===== DNS-pinned HTTP opener(防止 DNS rebinding)===== +import http.client as _http_client + +class _PinnedHTTPConnection(_http_client.HTTPConnection): + """强制连接到预解析的 IP,防止 DNS rebinding TOCTOU""" + def __init__(self, *args, pinned_ip=None, pinned_port=None, **kwargs): + super().__init__(*args, **kwargs) + self._pinned_ip = pinned_ip + self._pinned_port = pinned_port + + def connect(self): + self.sock = _socket.create_connection( + (self._pinned_ip or self.host, self._pinned_port or self.port), + self.timeout + ) + +class _PinnedHTTPSConnection(_http_client.HTTPSConnection): + def __init__(self, *args, pinned_ip=None, pinned_port=None, original_hostname=None, **kwargs): + super().__init__(*args, **kwargs) + self._pinned_ip = pinned_ip + self._pinned_port = pinned_port + self._original_hostname = original_hostname or self.host + + def connect(self): + import ssl as _ssl + self.sock = _socket.create_connection( + (self._pinned_ip or self.host, self._pinned_port or self.port), + self.timeout + ) + ctx = _ssl.create_default_context() + self.sock = ctx.wrap_socket(self.sock, server_hostname=self._original_hostname) + +class _PinnedHTTPHandler(_urllib_request.HTTPHandler): + def __init__(self, pinned_ip, pinned_port): + super().__init__() + self._pinned_ip = pinned_ip + self._pinned_port = pinned_port + + def http_open(self, req): + return self.do_open( + lambda host, **kwargs: _PinnedHTTPConnection( + host, pinned_ip=self._pinned_ip, pinned_port=self._pinned_port, **kwargs + ), + req + ) + +class _PinnedHTTPSHandler(_urllib_request.HTTPSHandler): + def __init__(self, pinned_ip, pinned_port, original_hostname): + super().__init__() + self._pinned_ip = pinned_ip + self._pinned_port = pinned_port + self._original_hostname = original_hostname + + def https_open(self, req): + return self.do_open( + lambda host, **kwargs: _PinnedHTTPSConnection( + host, pinned_ip=self._pinned_ip, pinned_port=self._pinned_port, + original_hostname=self._original_hostname, **kwargs + ), + req + ) + +def _build_pinned_opener(resolved_ip, port, hostname): + return _urllib_request.build_opener( + _PinnedHTTPHandler(resolved_ip, port), + _PinnedHTTPSHandler(resolved_ip, port, hostname) + ) + + +class _SystemHelper: + """安全的系统辅助类 — 所有模块引用通过闭包捕获,外部无法访问""" + __slots__ = () + + @staticmethod + def http_request(url, method='GET', headers=None, body=None, timeout=None): + global _request_count + _request_count += 1 + if _request_count > _REQUEST_LIMITS['max_requests']: + raise RuntimeError(f"Request limit exceeded: max {_REQUEST_LIMITS['max_requests']}") + + parsed = _urllib_parse.urlparse(url) + if parsed.scheme + ':' not in _REQUEST_LIMITS['allowed_protocols']: + raise RuntimeError(f"Protocol {parsed.scheme}: not allowed") + + hostname = parsed.hostname + try: + infos = _socket.getaddrinfo(hostname, parsed.port or (443 if parsed.scheme == 'https' else 80)) + for info in infos: + ip = info[4][0] + if _is_blocked_ip(ip): + raise RuntimeError("Request to private/internal network not allowed") + resolved_ip = infos[0][4][0] if infos else None + except _socket.gaierror as e: + raise RuntimeError(f"DNS resolution failed: {e}") + + if timeout is None: + timeout = _REQUEST_LIMITS['timeout'] + else: + timeout = min(timeout, _REQUEST_LIMITS['timeout']) + + if headers is None: + headers = {} + + data = None + if body is not None: + if isinstance(body, dict): + data = json.dumps(body).encode('utf-8') + if 'Content-Type' not in headers and 'content-type' not in headers: + headers['Content-Type'] = 'application/json' + elif isinstance(body, str): + data = body.encode('utf-8') + else: + data = body + + if data is not None and len(data) > _REQUEST_LIMITS['max_request_body_size']: + raise RuntimeError("Request body too large") + + # 使用自定义 handler 强制连接到预解析的 IP,防止 DNS rebinding + port = parsed.port or (443 if parsed.scheme == 'https' else 80) + opener = _build_pinned_opener(resolved_ip, port, hostname) + req = _urllib_request.Request(url, data=data, headers=headers, method=method.upper()) + try: + resp = opener.open(req, timeout=timeout) + resp_data = resp.read(_REQUEST_LIMITS['max_response_size'] + 1) + if len(resp_data) > _REQUEST_LIMITS['max_response_size']: + raise RuntimeError("Response too large") + return { + 'status': resp.status, + 'headers': dict(resp.headers), + 'data': resp_data.decode('utf-8', errors='replace') + } + except _urllib_request.URLError as e: + raise RuntimeError(f"HTTP request failed: {e}") + + # 驼峰别名,与 JS 端 SystemHelper API 保持一致 + httpRequest = http_request + + +system_helper = _SystemHelper() +SystemHelper = system_helper + +# Legacy global functions (backward compatibility, standalone) +def count_token(text): + if not isinstance(text, str): + text = str(text) + return _math.ceil(len(text) / 4) + +def str_to_base64(text, prefix=''): + b64 = _base64.b64encode(text.encode('utf-8')).decode('utf-8') + return prefix + b64 + +def create_hmac(algorithm, secret): + timestamp = str(int(_time.time() * 1000)) + string_to_sign = timestamp + '\n' + secret + h = _hmac.new(secret.encode('utf-8'), string_to_sign.encode('utf-8'), algorithm) + sign = _urllib_parse.quote(_base64.b64encode(h.digest()).decode('utf-8')) + return {"timestamp": timestamp, "sign": sign} + +def delay(ms): + if ms > 10000: + raise ValueError("Delay must be <= 10000ms") + _time.sleep(ms / 1000) + +_request_count = 0 + +# ===== __import__ 拦截(init 后设置)===== +_original_import = _builtins.__import__ +_allowed_modules = set() + +_stdlib_paths = [] +_site_packages_paths = [] +for _key in ('stdlib', 'platstdlib'): + _p = _sysconfig.get_path(_key) + if _p: + _stdlib_paths.append(_p) +for _key in ('purelib', 'platlib'): + _p = _sysconfig.get_path(_key) + if _p: + _stdlib_paths.append(_p) + _site_packages_paths.append(_p) +_stdlib_paths.append('//) + # stdlib 内部的间接 import(如 locale -> os)放行 + if len(stack) >= 2: + caller_fn = stack[-2].filename or '' + if caller_fn in ('', '', ''): + raise ImportError(f"Module '{name}' is not in the allowlist.") + return _original_import(name, *args, **kwargs) + + +# ===== 文件系统限制 ===== +_original_open = open + +_open_guard = False + +def _validate_user_code(code: str): + """执行前的静态安全检查,阻断已知高危反射链。""" + try: + tree = _ast.parse(code) + except SyntaxError: + # 语法错误交由后续 exec 抛出原始错误信息 + return + + for node in _ast.walk(tree): + # 直接属性访问:obj.__subclasses__ + if isinstance(node, _ast.Attribute) and node.attr == '__subclasses__': + raise RuntimeError("Access to __subclasses__ is not allowed in sandbox") + + # 动态属性访问:getattr(obj, '__subclasses__') + if ( + isinstance(node, _ast.Call) + and isinstance(node.func, _ast.Name) + and node.func.id == 'getattr' + and len(node.args) >= 2 + and isinstance(node.args[1], _ast.Constant) + and node.args[1].value == '__subclasses__' + ): + raise RuntimeError("Access to __subclasses__ is not allowed in sandbox") + + + +def _restricted_open(*args, **kwargs): + """限制 open() — 只允许第三方库内部调用,禁止用户代码直接读写文件""" + global _open_guard + if _open_guard: + return _original_open(*args, **kwargs) + _open_guard = True + try: + stack = _tb.extract_stack() + finally: + _open_guard = False + if len(stack) >= 2: + caller_fn = stack[-2].filename or '' + # 用户代码()不允许直接 open + if caller_fn in ('', '', ''): + raise PermissionError("File system access is not allowed in sandbox") + # 非 stdlib、非 site-packages、非 worker 自身的帧也不允许 + if not _is_stdlib_frame(caller_fn) and not _is_site_packages_frame(caller_fn) and caller_fn != __file__: + raise PermissionError("File system access is not allowed in sandbox") + return _original_open(*args, **kwargs) + + +# ===== 日志收集 ===== +_logs = [] +_log_size = 0 +_MAX_LOG_SIZE = 1024 * 1024 # 1MB +_orig_print = print + + +def _safe_print(*args, **kwargs): + global _log_size + line = ' '.join(str(a) for a in args) + if _log_size + len(line) <= _MAX_LOG_SIZE: + _logs.append(line) + _log_size += len(line) + + +# ===== 输出 ===== +def write_line(obj): + sys.stdout.write(json.dumps(obj, ensure_ascii=False, default=str) + '\n') + sys.stdout.flush() + + +# ===== 超时信号处理 ===== +_timeout_stage = 0 # 0=未触发, 1=第一次(可恢复), 2=第二次(强制退出) + +def _timeout_handler(signum, frame): + global _timeout_stage + _timeout_stage += 1 + if _timeout_stage >= 2: + # 第二次 alarm:用户代码 catch 了第一次 TimeoutError,强制写错误并退出当前执行 + # 通过 SystemExit 强制终止(不可被 except Exception 捕获) + raise SystemExit("Script execution timed out (forced)") + # 第一次 alarm:设置 1s 后的兜底 alarm + signal.alarm(1) + raise TimeoutError("Script execution timed out") + + +# ===== 模块状态保护 ===== +# 用户代码可能污染共享模块(如 json.dumps = lambda x: "hacked"), +# 需要在每次执行前快照、执行后恢复。 +_PROTECTED_MODULES = [json, _math, _time, _base64, _hashlib, _hmac, _copy] + + +def _snapshot_modules(): + """保存受保护模块的属性快照""" + snapshots = [] + for mod in _PROTECTED_MODULES: + attrs = {} + for name in dir(mod): + if not name.startswith('__'): + try: + attrs[name] = getattr(mod, name) + except Exception: + pass + snapshots.append((mod, attrs)) + return snapshots + + +def _restore_modules(snapshots): + """恢复受保护模块的属性""" + for mod, attrs in snapshots: + # 删除用户可能添加的新属性 + current_names = set(n for n in dir(mod) if not n.startswith('__')) + original_names = set(attrs.keys()) + for name in current_names - original_names: + try: + delattr(mod, name) + except Exception: + pass + # 恢复原始属性 + for name, val in attrs.items(): + try: + setattr(mod, name, val) + except Exception: + pass + + +# ===== 主循环 ===== +def main_loop(): + global _allowed_modules, _request_count, _logs + + initialized = False + + for line in sys.stdin: + line = line.strip() + if not line: + continue + + try: + msg = json.loads(line) + except json.JSONDecodeError: + write_line({"success": False, "message": "Invalid JSON input"}) + continue + + # 初始化 + if not initialized: + if msg.get('type') == 'init': + _allowed_modules = set(msg.get('allowedModules', [])) + _init_request_limits(msg.get('requestLimits')) + _builtins.__import__ = _safe_import + global _builtins_proxy + _builtins_proxy = _BuiltinsProxy(_builtins) + write_line({"type": "ready"}) + initialized = True + else: + write_line({"success": False, "message": "Expected init message"}) + continue + + # ping 健康检查:立即回复 pong + if msg.get('type') == 'ping': + write_line({"type": "pong"}) + continue + + # 执行任务 + code = msg.get('code', '') + variables = msg.get('variables', {}) + timeout_ms = msg.get('timeoutMs', 10000) + # 修复精度:向上取整而非截断,最小 1 秒 + timeout_s = max(1, -(-timeout_ms // 1000)) # ceil division + + _request_count = 0 + _logs = [] + _log_size = 0 + _timeout_stage = 0 + + # 替换 print + _builtins.print = _safe_print + + # 每次执行前强制恢复 __import__,防止上次用户代码篡改 + _builtins.__import__ = _safe_import + + # 保存模块状态快照 + _mod_snapshots = _snapshot_modules() + + try: + # 设置超时(双重 alarm:第一次抛 TimeoutError,第二次兜底防止用户 catch) + signal.signal(signal.SIGALRM, _timeout_handler) + signal.alarm(timeout_s) + + # 构建受限的 __builtins__ 字典,移除 _original_import 等内部引用 + _safe_builtins = {} + for _name in dir(_builtins): + if _name.startswith('_') and _name not in ( + '__name__', '__doc__', '__import__', '__build_class__', + ): + continue + _safe_builtins[_name] = getattr(_builtins, _name) + # 确保 __import__ 指向安全版本 + _safe_builtins['__import__'] = _safe_import + _safe_builtins['__build_class__'] = _builtins.__build_class__ + # 限制 open() — 禁止用户代码直接读写文件系统 + _safe_builtins['open'] = _restricted_open + + # H3: 屏蔽 object.__subclasses__,防止通过子类树找到已加载的危险模块 + class _SafeObject(object): + __subclasses__ = None + _safe_builtins['object'] = _SafeObject + + # 构建执行环境 + exec_globals = { + '__builtins__': _safe_builtins, + 'variables': variables, + 'SystemHelper': system_helper, + 'system_helper': system_helper, + 'count_token': count_token, + 'str_to_base64': str_to_base64, + 'create_hmac': create_hmac, + 'delay': delay, + 'http_request': system_helper.http_request, + 'print': _safe_print, + 'json': json, + 'math': _math, + 'time': _time, + } + # 展开 variables 到全局(过滤保留关键字,防止覆盖沙箱安全全局变量) + _reserved_keys = frozenset(exec_globals.keys()) + for k, v in variables.items(): + if k not in _reserved_keys: + exec_globals[k] = v + + # 执行前静态检查:阻断 __subclasses__ 反射链 + _validate_user_code(code) + + # 执行用户代码 + exec(code, exec_globals) + + # 取出 main 函数 + user_main = exec_globals.get('main') + if user_main is None: + raise RuntimeError("No 'main' function defined") + + # 调用 main + sig = _inspect_mod.signature(user_main) + params = list(sig.parameters.keys()) + + if len(params) == 0: + result = user_main() + elif len(params) == 1: + result = user_main(variables) + else: + call_args = [] + for p in params: + if p in variables: + call_args.append(variables[p]) + elif sig.parameters[p].default is not _inspect_mod.Parameter.empty: + break + else: + raise TypeError(f"Missing required argument: '{p}'") + result = user_main(*call_args) + + signal.alarm(0) + write_line({ + "success": True, + "data": {"codeReturn": result, "log": '\n'.join(_logs)} + }) + + except (Exception, SystemExit) as e: + signal.alarm(0) + write_line({"success": False, "message": str(e)}) + + finally: + signal.alarm(0) + _builtins.print = _orig_print + # 恢复模块状态,防止用户代码污染影响后续请求 + _restore_modules(_mod_snapshots) + + +if __name__ == '__main__': + main_loop() diff --git a/projects/sandbox/src/pool/worker.ts b/projects/sandbox/src/pool/worker.ts new file mode 100644 index 0000000000..7448a7c4ee --- /dev/null +++ b/projects/sandbox/src/pool/worker.ts @@ -0,0 +1,439 @@ +/** + * Worker 长驻进程 - 真正的 TS 源文件 + * + * 启动后先从 stdin 读取第一行作为初始化配置(allowedModules 等), + * 然后进入主循环,逐行接收任务执行。 + * + * 协议: + * 第 1 行:{"type":"init","allowedModules":["lodash","dayjs",...]} + * 后续每行:{"code":"...","variables":{},"timeoutMs":10000} + * 每行输出:{"success":true,"data":{...}} 或 {"success":false,"message":"..."} + */ +import { createInterface } from 'readline'; +import * as crypto from 'crypto'; +import * as http from 'http'; +import * as https from 'https'; +import * as dns from 'dns'; +import * as net from 'net'; +const _OriginalFunction = Function; + +// ===== 安全 shim ===== +// 只拦截对 Function 相关原型的访问,防止通过原型链拿到原始构造器 +// 不再全局覆盖 Object.getPrototypeOf,避免破坏 lodash 等合法库 +const _origGetProto = Object.getPrototypeOf; +const _origReflectGetProto = Reflect.getPrototypeOf; +const _blockedProtos = new Set([_OriginalFunction.prototype]); + +Object.getPrototypeOf = function (obj: any) { + const proto = _origGetProto(obj); + if (_blockedProtos.has(proto)) return Object.create(null) as any; + return proto; +}; +Reflect.getPrototypeOf = function (obj: any): any { + const proto = _origReflectGetProto(obj); + if (_blockedProtos.has(proto)) return Object.create(null); + return proto; +}; +Object.setPrototypeOf = () => false as any; +Reflect.setPrototypeOf = () => false; +if ((Error as any).prepareStackTrace) delete (Error as any).prepareStackTrace; +if ((Error as any).captureStackTrace) delete (Error as any).captureStackTrace; + +const _SafeFunction = function (..._args: any[]) { + throw new Error('Function constructor is not allowed in sandbox'); +} as unknown as FunctionConstructor; +Object.defineProperty(_SafeFunction, 'prototype', { + value: _OriginalFunction.prototype, + writable: false, + configurable: false +}); +Object.defineProperty(_OriginalFunction.prototype, 'constructor', { + value: _SafeFunction, + writable: false, + configurable: false +}); +(globalThis as any).Function = _SafeFunction; + +// 锁定 AsyncFunction、GeneratorFunction、AsyncGeneratorFunction 构造器 +// 防止通过 (async function(){}).constructor("...") 绕过沙盒 +const _AsyncFunction = async function () {}.constructor; +const _GeneratorFunction = function* () {}.constructor; +const _AsyncGeneratorFunction = async function* () {}.constructor; + +for (const FnCtor of [_AsyncFunction, _GeneratorFunction, _AsyncGeneratorFunction]) { + Object.defineProperty(FnCtor.prototype, 'constructor', { + value: _SafeFunction, + writable: false, + configurable: false + }); +} + +// C2: Bun API 在用户代码中通过函数参数遮蔽来阻止访问(见 userFn 构造处) +// 不在全局移除,因为 Bun 运行时自身依赖 globalThis.Bun + +(globalThis as any).fetch = undefined; +(globalThis as any).XMLHttpRequest = undefined; +(globalThis as any).WebSocket = undefined; + +// ===== process 对象加固 ===== +// 保存 worker 自身需要的引用 +const _workerStdout = process.stdout; +const _workerStdin = process.stdin; + +if (typeof process !== 'undefined') { + // 删除危险方法 + const dangerousMethods = [ + 'binding', + 'dlopen', + '_linkedBinding', + 'kill', + 'chdir', + 'exit', + 'emitWarning', + 'send', + 'disconnect', + '_debugProcess', + '_debugEnd', + '_startProfilerIdleNotifier', + '_stopProfilerIdleNotifier', + 'reallyExit', + 'abort', + 'umask', + 'setuid', + 'setgid', + 'seteuid', + 'setegid', + 'setgroups', + 'initgroups' + ]; + for (const method of dangerousMethods) { + try { + Object.defineProperty(process, method, { + value: undefined, + writable: false, + configurable: false + }); + } catch {} + } + + // 清理 env 敏感变量并冻结 + const sensitivePatterns = [ + /secret/i, + /password/i, + /token/i, + /key/i, + /credential/i, + /auth/i, + /private/i, + /aws/i, + /api_key/i, + /apikey/i + ]; + for (const key of Object.keys(process.env)) { + if (sensitivePatterns.some((p) => p.test(key))) { + delete process.env[key]; + } + } + Object.freeze(process.env); +} + +// ===== 网络安全 ===== +function ipToLong(ip: string): number { + const parts = ip.split('.').map(Number); + return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; +} + +function isBlockedIP(rawIp: string): boolean { + let ip = rawIp; + if (!ip) return true; + if (ip === '::1' || ip === '::') return true; + if (ip.startsWith('::ffff:')) ip = ip.slice(7); + if (ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80')) return true; + if (!net.isIPv4(ip)) return false; + const ipLong = ipToLong(ip); + const cidrs: [string, number][] = [ + ['10.0.0.0', 8], + ['172.16.0.0', 12], + ['192.168.0.0', 16], + ['169.254.0.0', 16], + ['127.0.0.0', 8], + ['0.0.0.0', 8] + ]; + for (const [base, bits] of cidrs) { + const mask = (0xffffffff << (32 - bits)) >>> 0; + if ((ipLong & mask) === (ipToLong(base) & mask)) return true; + } + return false; +} + +function dnsResolve(hostname: string): Promise { + return new Promise((resolve, reject) => { + dns.lookup(hostname, { all: true }, (err, addresses) => { + if (err) return reject(err); + resolve(addresses.map((a: any) => a.address)); + }); + }); +} + +const REQUEST_LIMITS = { + maxRequests: 30, + timeoutMs: 60000, + maxResponseSize: 10 * 1024 * 1024, + maxRequestBodySize: 5 * 1024 * 1024, + allowedProtocols: ['http:', 'https:'] +}; + +let requestCount = 0; + +// ===== Legacy global functions (backward compatibility, not on SystemHelper) ===== +function countToken(text: any): number { + return Math.ceil(String(text).length / 4); +} +function strToBase64(str: string, prefix = ''): string { + return prefix + Buffer.from(str, 'utf-8').toString('base64'); +} +function createHmac(algorithm: string, secret: string) { + const timestamp = Date.now().toString(); + const stringToSign = timestamp + '\n' + secret; + const hmac = crypto.createHmac(algorithm, secret); + hmac.update(stringToSign, 'utf8'); + return { timestamp, sign: encodeURIComponent(hmac.digest('base64')) }; +} +function delay(ms: number): Promise { + if (ms > 10000) throw new Error('Delay must be <= 10000ms'); + return new Promise((r) => setTimeout(r, ms)); +} + +// ===== SystemHelper ===== +const SystemHelper = { + async httpRequest(url: string, opts: any = {}): Promise { + if (++requestCount > REQUEST_LIMITS.maxRequests) { + throw new Error('Request limit exceeded'); + } + const parsed = new URL(url); + if (!REQUEST_LIMITS.allowedProtocols.includes(parsed.protocol)) { + throw new Error('Protocol not allowed'); + } + const ips = await dnsResolve(parsed.hostname); + for (const ip of ips) { + if (isBlockedIP(ip)) throw new Error('Request to private network not allowed'); + } + const method = (opts.method || 'GET').toUpperCase(); + const headers = opts.headers || {}; + const body = + opts.body != null + ? typeof opts.body === 'string' + ? opts.body + : JSON.stringify(opts.body) + : null; + if (body && body.length > REQUEST_LIMITS.maxRequestBodySize) { + throw new Error('Request body too large'); + } + const timeoutSeconds = + typeof opts.timeout === 'number' && Number.isFinite(opts.timeout) && opts.timeout > 0 + ? opts.timeout + : REQUEST_LIMITS.timeoutMs / 1000; + const timeout = Math.min(Math.ceil(timeoutSeconds * 1000), REQUEST_LIMITS.timeoutMs); + if (body && !headers['Content-Type'] && !headers['content-type']) { + headers['Content-Type'] = 'application/json'; + } + const resolvedIP = ips[0]; + if (!headers['Host'] && !headers['host']) { + headers['Host'] = parsed.hostname + (parsed.port ? ':' + parsed.port : ''); + } + const lib = parsed.protocol === 'https:' ? https : http; + return new Promise((resolve, reject) => { + const req = lib.request( + { + method, + headers, + timeout, + hostname: resolvedIP, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + servername: parsed.hostname + }, + (res: any) => { + const chunks: Buffer[] = []; + let size = 0; + res.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > REQUEST_LIMITS.maxResponseSize) { + req.destroy(); + reject(new Error('Response too large')); + return; + } + chunks.push(chunk); + }); + res.on('end', () => { + const data = Buffer.concat(chunks).toString('utf-8'); + const h: Record = {}; + for (const [k, v] of Object.entries(res.headers)) h[k] = v; + resolve({ status: res.statusCode, statusText: res.statusMessage, headers: h, data }); + }); + res.on('error', reject); + } + ); + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + req.on('error', reject); + if (body) req.write(body); + req.end(); + }); + } +}; + +const httpRequest = SystemHelper.httpRequest; + +// ===== 模块白名单(启动后由 init 消息设置)===== +let allowedModules = new Set(); +const origRequire = require; +const safeRequire = new Proxy(origRequire, { + apply(target, thisArg, args) { + if (!allowedModules.has(args[0])) { + throw new Error(`Module '${args[0]}' is not allowed in sandbox`); + } + return Reflect.apply(target, thisArg, args); + } +}); + +// ===== 输出辅助 ===== +function writeLine(obj: any): void { + _workerStdout.write(JSON.stringify(obj) + '\n'); +} + +// ===== 主循环 ===== +const rl = createInterface({ input: _workerStdin, terminal: false }); +let initialized = false; + +rl.on('line', async (line: string) => { + let msg: any; + try { + msg = JSON.parse(line); + } catch { + writeLine({ success: false, message: 'Invalid JSON input' }); + return; + } + + // 第一条消息:初始化配置 + if (!initialized) { + if (msg.type === 'init') { + allowedModules = new Set(msg.allowedModules || []); + // 从 init 消息读取请求限制(由 process-pool 从 config 传入) + if (msg.requestLimits) { + if (msg.requestLimits.maxRequests != null) + REQUEST_LIMITS.maxRequests = msg.requestLimits.maxRequests; + if (msg.requestLimits.timeoutMs != null) + REQUEST_LIMITS.timeoutMs = msg.requestLimits.timeoutMs; + if (msg.requestLimits.maxResponseSize != null) + REQUEST_LIMITS.maxResponseSize = msg.requestLimits.maxResponseSize; + if (msg.requestLimits.maxRequestBodySize != null) + REQUEST_LIMITS.maxRequestBodySize = msg.requestLimits.maxRequestBodySize; + } + writeLine({ type: 'ready' }); + initialized = true; + } else { + writeLine({ success: false, message: 'Expected init message' }); + } + return; + } + + // ping 健康检查:立即回复 pong + if (msg.type === 'ping') { + writeLine({ type: 'pong' }); + return; + } + + // 后续消息:执行任务 + const { code, variables, timeoutMs } = msg; + requestCount = 0; // 每次任务重置 + + const logs: string[] = []; + let logSize = 0; + const MAX_LOG_SIZE = 1024 * 1024; // 1MB + const safeConsole = { + log(...args: any[]) { + const line = args + .map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a))) + .join(' '); + if (logSize + line.length <= MAX_LOG_SIZE) { + logs.push(line); + logSize += line.length; + } + } + }; + + let timer: ReturnType | undefined; + try { + // 静态检查:拦截 import() 动态导入,防止绕过 require 白名单 + // 匹配 import( 但排除注释中的(简单启发式) + if (/\bimport\s*\(/.test(code)) { + writeLine({ + success: false, + message: 'Dynamic import() is not allowed in sandbox. Use require() instead.' + }); + return; + } + + const resultPromise = (async () => { + // C2 + #19: 通过函数参数遮蔽危险全局对象,用户代码无法访问 Bun/process/globalThis + const _sandboxProcess = Object.freeze({ + env: Object.freeze({}), + cwd: () => '/sandbox', + version: process.version, + platform: process.platform + }); + const userFn = new (_OriginalFunction as any)( + 'require', + 'console', + 'SystemHelper', + 'countToken', + 'strToBase64', + 'createHmac', + 'delay', + 'httpRequest', + 'variables', + 'Bun', + 'process', + 'globalThis', + 'global', + code + '\nreturn main;' + ); + const main = userFn( + safeRequire, + safeConsole, + SystemHelper, + countToken, + strToBase64, + createHmac, + delay, + httpRequest, + variables || {}, + undefined, + _sandboxProcess, + undefined, + undefined + ); + return await main(variables || {}); + })(); + + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Script execution timed out after ${timeoutMs}ms`)), + timeoutMs || 10000 + ); + }); + + const result = await Promise.race([resultPromise, timeoutPromise]); + clearTimeout(timer); + writeLine({ + success: true, + data: { codeReturn: result === undefined ? null : result, log: logs.join('\n') } + }); + } catch (err: any) { + clearTimeout(timer); + writeLine({ success: false, message: err?.message ?? String(err) }); + } +}); diff --git a/projects/sandbox/src/response.ts b/projects/sandbox/src/response.ts deleted file mode 100644 index dd2acd4db9..0000000000 --- a/projects/sandbox/src/response.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; - -@Injectable() -export class ResponseInterceptor implements NestInterceptor { - intercept(context: ExecutionContext, next: CallHandler): Observable { - return next.handle().pipe( - map((data) => ({ - success: true, - data - })) - ); - } -} diff --git a/projects/sandbox/src/sandbox/constants.ts b/projects/sandbox/src/sandbox/constants.ts deleted file mode 100644 index 900132d6f9..0000000000 --- a/projects/sandbox/src/sandbox/constants.ts +++ /dev/null @@ -1,269 +0,0 @@ -export const pythonScript = ` -import os -import subprocess -import json -import ast -import base64 - -def extract_imports(code): - tree = ast.parse(code) - imports = [] - for node in ast.walk(tree): - if isinstance(node, (ast.Import, ast.ImportFrom)): - if isinstance(node, ast.Import): - for alias in node.names: - imports.append(f"import {alias.name}") - elif isinstance(node, ast.ImportFrom): - module = node.module - for alias in node.names: - imports.append(f"from {module} import {alias.name}") - return imports - -seccomp_prefix = """ -import platform -import sys - -# Skip seccomp on macOS since it's Linux-specific -if platform.system() == 'Linux': - try: - from seccomp import * - import errno - allowed_syscalls = [ - # File operations - READ ONLY (removed SYS_WRITE) - "syscall.SYS_READ", - # Removed "syscall.SYS_WRITE" - no general write access - "syscall.SYS_OPEN", # Still needed for reading files - "syscall.SYS_OPENAT", # Still needed for reading files - "syscall.SYS_CLOSE", - "syscall.SYS_FSTAT", - "syscall.SYS_LSTAT", - "syscall.SYS_STAT", - "syscall.SYS_NEWFSTATAT", - "syscall.SYS_LSEEK", - "syscall.SYS_GETDENTS64", - "syscall.SYS_FCNTL", - "syscall.SYS_ACCESS", - "syscall.SYS_FACCESSAT", - - # Memory management - essential for Python - "syscall.SYS_MMAP", - "syscall.SYS_BRK", - "syscall.SYS_MPROTECT", - "syscall.SYS_MUNMAP", - "syscall.SYS_MREMAP", - - # Process/thread operations - "syscall.SYS_GETUID", - "syscall.SYS_GETGID", - "syscall.SYS_GETEUID", - "syscall.SYS_GETEGID", - "syscall.SYS_GETPID", - "syscall.SYS_GETPPID", - "syscall.SYS_GETTID", - "syscall.SYS_EXIT", - "syscall.SYS_EXIT_GROUP", - - # Signal handling - "syscall.SYS_RT_SIGACTION", - "syscall.SYS_RT_SIGPROCMASK", - "syscall.SYS_RT_SIGRETURN", - "syscall.SYS_SIGALTSTACK", - - # Time operations - "syscall.SYS_CLOCK_GETTIME", - "syscall.SYS_GETTIMEOFDAY", - "syscall.SYS_TIME", - - # Threading/synchronization - "syscall.SYS_FUTEX", - "syscall.SYS_SET_ROBUST_LIST", - "syscall.SYS_GET_ROBUST_LIST", - "syscall.SYS_CLONE", - - # System info - "syscall.SYS_UNAME", - "syscall.SYS_ARCH_PRCTL", - "syscall.SYS_RSEQ", - - # I/O operations - "syscall.SYS_IOCTL", - "syscall.SYS_POLL", - "syscall.SYS_SELECT", - "syscall.SYS_PSELECT6", - - # Process scheduling - "syscall.SYS_SCHED_YIELD", - "syscall.SYS_SCHED_GETAFFINITY", - - # Additional Python runtime essentials - "syscall.SYS_GETRANDOM", - "syscall.SYS_GETCWD", - "syscall.SYS_READLINK", - "syscall.SYS_READLINKAT", - ] - allowed_syscalls_tmp = allowed_syscalls - L = [] - for item in allowed_syscalls_tmp: - item = item.strip() - parts = item.split(".")[1][4:].lower() - L.append(parts) - f = SyscallFilter(defaction=KILL) - for item in L: - f.add_rule(ALLOW, item) - # Only allow writing to stdout and stderr for output - f.add_rule(ALLOW, "write", Arg(0, EQ, sys.stdout.fileno())) - f.add_rule(ALLOW, "write", Arg(0, EQ, sys.stderr.fileno())) - # Remove other write-related syscalls - # f.add_rule(ALLOW, 307) # Removed - might be file creation - # f.add_rule(ALLOW, 318) # Removed - might be file creation - # f.add_rule(ALLOW, 334) # Removed - might be file creation - f.load() - except ImportError: - # seccomp module not available, skip security restrictions - pass -""" - -def remove_print_statements(code): - class PrintRemover(ast.NodeTransformer): - def visit_Expr(self, node): - if ( - isinstance(node.value, ast.Call) - and isinstance(node.value.func, ast.Name) - and node.value.func.id == "print" - ): - return None - return node - - tree = ast.parse(code) - modified_tree = PrintRemover().visit(tree) - ast.fix_missing_locations(modified_tree) - return ast.unparse(modified_tree) - -def detect_dangerous_imports(code): - # Add file writing modules to the blacklist - dangerous_modules = [ - "os", "sys", "subprocess", "shutil", "socket", "ctypes", - "multiprocessing", "threading", "pickle", - # Additional modules that can write files - "tempfile", "pathlib", "io", "fileinput" - ] - tree = ast.parse(code) - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - if alias.name in dangerous_modules: - return alias.name - elif isinstance(node, ast.ImportFrom): - if node.module in dangerous_modules: - return node.module - return None - -def detect_file_write_operations(code): - """Detect potential file writing operations in code""" - dangerous_patterns = [ - 'open(', 'file(', 'write(', 'writelines(', - 'with open', 'f.write', '.write(', - 'create', 'mkdir', 'makedirs' - ] - - for pattern in dangerous_patterns: - if pattern in code: - return f"File write operation detected: {pattern}" - return None - -def run_pythonCode(data:dict): - if not data or "code" not in data: - return {"error": "Invalid request format: missing code"} - - code = data.get("code") - if not code or not code.strip(): - return {"error": "Code cannot be empty"} - - code = remove_print_statements(code) - dangerous_import = detect_dangerous_imports(code) - if dangerous_import: - return {"error": f"Importing {dangerous_import} is not allowed."} - - # Check for file write operations - write_operation = detect_file_write_operations(code) - if write_operation: - return {"error": f"File write operations are not allowed: {write_operation}"} - - # Handle variables - default to empty dict if not provided or None - variables = data.get("variables", {}) - if variables is None: - variables = {} - - imports = "\\n".join(extract_imports(code)) - var_def = "" - - # Process variables with proper validation - for k, v in variables.items(): - if not isinstance(k, str) or not k.strip(): - return {"error": f"Invalid variable name: {repr(k)}"} - - # Use repr() to properly handle Python True/False/None values - try: - one_var = f"{k} = {repr(v)}\\n" - var_def = var_def + one_var - except Exception as e: - return {"error": f"Error processing variable {k}: {str(e)}"} - - # Create a safe main function call with error handling - output_code = '''if __name__ == '__main__': - import inspect - try: - # Get main function signature - sig = inspect.signature(main) - params = list(sig.parameters.keys()) - - # Create arguments dict from available variables - available_vars = {''' + ', '.join([f'"{k}": {k}' for k in variables.keys()]) + '''} - - # Match parameters with available variables - args = [] - kwargs = {} - - for param_name in params: - if param_name in available_vars: - args.append(available_vars[param_name]) - else: - # Check if parameter has default value - param = sig.parameters[param_name] - if param.default is not inspect.Parameter.empty: - break # Stop adding positional args, rest will use defaults - else: - raise TypeError(f"main() missing required argument: '{param_name}'. Available variables: {list(available_vars.keys())}") - - # Call main function - if args: - res = main(*args) - else: - res = main() - - print(res) - except Exception as e: - print({"error": f"Error calling main function: {str(e)}"}) -''' - code = imports + "\\n" + seccomp_prefix + "\\n" + var_def + "\\n" + code + "\\n" + output_code - - # Note: We still need to create the subprocess file for execution, - # but user code cannot write additional files - tmp_file = os.path.join(data["tempDir"], "subProcess.py") - with open(tmp_file, "w", encoding="utf-8") as f: - f.write(code) - try: - result = subprocess.run(["python3", tmp_file], capture_output=True, text=True, timeout=10) - if result.returncode == -31: - return {"error": "Dangerous behavior detected (likely file write attempt)."} - if result.stderr != "": - return {"error": result.stderr} - - out = ast.literal_eval(result.stdout.strip()) - return out - except subprocess.TimeoutExpired: - return {"error": "Timeout error or blocked by system security policy"} - except Exception as e: - return {"error": str(e)} - -`; diff --git a/projects/sandbox/src/sandbox/dto/create-sandbox.dto.ts b/projects/sandbox/src/sandbox/dto/create-sandbox.dto.ts deleted file mode 100644 index 4d04c4cec9..0000000000 --- a/projects/sandbox/src/sandbox/dto/create-sandbox.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -export class RunCodeDto { - code: string; - variables: object; -} - -export class RunCodeResponse { - codeReturn: Record; - log: string; -} diff --git a/projects/sandbox/src/sandbox/jsFn/crypto.ts b/projects/sandbox/src/sandbox/jsFn/crypto.ts deleted file mode 100644 index 09474ad0bf..0000000000 --- a/projects/sandbox/src/sandbox/jsFn/crypto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as crypto from 'crypto'; -import * as querystring from 'querystring'; - -export const createHmac = (algorithm: string, secret: string) => { - const timestamp = Date.now().toString(); - const stringToSign = `${timestamp}\n${secret}`; - - // 创建 HMAC - const hmac = crypto.createHmac(algorithm, secret); - hmac.update(stringToSign, 'utf8'); - const signData = hmac.digest(); - - const sign = querystring.escape(Buffer.from(signData).toString('base64')); - - return { - timestamp, - sign - }; -}; diff --git a/projects/sandbox/src/sandbox/jsFn/delay.ts b/projects/sandbox/src/sandbox/jsFn/delay.ts deleted file mode 100644 index 67ceeee4b1..0000000000 --- a/projects/sandbox/src/sandbox/jsFn/delay.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const timeDelay = (time: number) => { - return new Promise((resolve, reject) => { - if (time > 10000) { - reject('Delay time must be less than 10'); - } - setTimeout(() => { - resolve(''); - }, time); - }); -}; diff --git a/projects/sandbox/src/sandbox/jsFn/str2Base64.ts b/projects/sandbox/src/sandbox/jsFn/str2Base64.ts deleted file mode 100644 index 6522ab824d..0000000000 --- a/projects/sandbox/src/sandbox/jsFn/str2Base64.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const strToBase64 = (str: string, prefix: string = '') => { - const base64_string = Buffer.from(str, 'utf-8').toString('base64'); - - return `${prefix}${base64_string}`; -}; diff --git a/projects/sandbox/src/sandbox/jsFn/tiktoken/index.ts b/projects/sandbox/src/sandbox/jsFn/tiktoken/index.ts deleted file mode 100644 index f28e15aac2..0000000000 --- a/projects/sandbox/src/sandbox/jsFn/tiktoken/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Tiktoken } from 'tiktoken/lite'; -const cl100k_base = require('tiktoken/encoders/cl100k_base'); - -export const countToken = (text: string = '') => { - const enc = new Tiktoken(cl100k_base.bpe_ranks, cl100k_base.special_tokens, cl100k_base.pat_str); - const encodeText = enc.encode(text); - return encodeText.length; -}; diff --git a/projects/sandbox/src/sandbox/sandbox.controller.spec.ts b/projects/sandbox/src/sandbox/sandbox.controller.spec.ts deleted file mode 100644 index 5f1d4dd4f1..0000000000 --- a/projects/sandbox/src/sandbox/sandbox.controller.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { SandboxController } from './sandbox.controller'; -import { SandboxService } from './sandbox.service'; - -describe('SandboxController', () => { - let controller: SandboxController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [SandboxController], - providers: [SandboxService] - }).compile(); - - controller = module.get(SandboxController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/projects/sandbox/src/sandbox/sandbox.controller.ts b/projects/sandbox/src/sandbox/sandbox.controller.ts deleted file mode 100644 index c26441c746..0000000000 --- a/projects/sandbox/src/sandbox/sandbox.controller.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Controller, Post, Body, HttpCode } from '@nestjs/common'; -import { RunCodeDto } from './dto/create-sandbox.dto'; -import { runJsSandbox, runPythonSandbox } from './utils'; - -@Controller('sandbox') -export class SandboxController { - constructor() {} - - @Post('/js') - @HttpCode(200) - runJs(@Body() codeProps: RunCodeDto) { - return runJsSandbox(codeProps); - } - - @Post('/python') - @HttpCode(200) - runPython(@Body() codeProps: RunCodeDto) { - return runPythonSandbox(codeProps); - } -} diff --git a/projects/sandbox/src/sandbox/sandbox.module.ts b/projects/sandbox/src/sandbox/sandbox.module.ts deleted file mode 100644 index df1429cab6..0000000000 --- a/projects/sandbox/src/sandbox/sandbox.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SandboxService } from './sandbox.service'; -import { SandboxController } from './sandbox.controller'; - -@Module({ - controllers: [SandboxController], - providers: [SandboxService] -}) -export class SandboxModule {} diff --git a/projects/sandbox/src/sandbox/sandbox.service.spec.ts b/projects/sandbox/src/sandbox/sandbox.service.spec.ts deleted file mode 100644 index fd953805ef..0000000000 --- a/projects/sandbox/src/sandbox/sandbox.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { SandboxService } from './sandbox.service'; - -describe('SandboxService', () => { - let service: SandboxService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [SandboxService] - }).compile(); - - service = module.get(SandboxService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/projects/sandbox/src/sandbox/sandbox.service.ts b/projects/sandbox/src/sandbox/sandbox.service.ts deleted file mode 100644 index d76ddbd309..0000000000 --- a/projects/sandbox/src/sandbox/sandbox.service.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { RunCodeDto } from './dto/create-sandbox.dto'; - -@Injectable() -export class SandboxService { - runJs(params: RunCodeDto) { - return {}; - } -} diff --git a/projects/sandbox/src/sandbox/utils.ts b/projects/sandbox/src/sandbox/utils.ts deleted file mode 100644 index b0955e6181..0000000000 --- a/projects/sandbox/src/sandbox/utils.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { RunCodeDto, RunCodeResponse } from 'src/sandbox/dto/create-sandbox.dto'; -import IsolatedVM, { ExternalCopy, Isolate, Reference } from 'isolated-vm'; -import { mkdtemp, writeFile } from 'fs/promises'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { rmSync } from 'fs'; -import { countToken } from './jsFn/tiktoken'; -import { timeDelay } from './jsFn/delay'; -import { strToBase64 } from './jsFn/str2Base64'; -import { createHmac } from './jsFn/crypto'; - -import { spawn } from 'child_process'; -import { pythonScript } from './constants'; -const CustomLogStr = 'CUSTOM_LOG'; - -export const runJsSandbox = async ({ - code, - variables = {} -}: RunCodeDto): Promise => { - /* - Rewrite code to add custom functions: Promise function; Log. - */ - function getFnCode(code: string) { - // rewrite log - code = code.replace(/console\.log/g, `${CustomLogStr}`); - - // Promise function rewrite - const rewriteSystemFn = ` - const thisDelay = (...args) => global_delay.applySyncPromise(undefined,args) -`; - - // rewrite delay - code = code.replace(/delay\((.*)\)/g, `thisDelay($1)`); - - const runCode = ` - (async() => { - try { - ${rewriteSystemFn} - ${code} - - const res = await main(variables, {}) - return JSON.stringify(res); - } catch(err) { - return JSON.stringify({ERROR: err?.message ?? err}) - } - }) -`; - return runCode; - } - // Register global function - function registerSystemFn(jail: IsolatedVM.Reference>) { - return Promise.all([ - jail.set('global_delay', new Reference(timeDelay)), - jail.set('countToken', countToken), - jail.set('strToBase64', strToBase64), - jail.set('createHmac', createHmac) - ]); - } - - const logData = []; - - const isolate = new Isolate({ memoryLimit: 32 }); - const context = await isolate.createContext(); - const jail = context.global; - - try { - // Add global variables - await Promise.all([ - jail.set('variables', new ExternalCopy(variables).copyInto()), - jail.set(CustomLogStr, function (...args) { - logData.push( - args - .map((item) => (typeof item === 'object' ? JSON.stringify(item, null, 2) : item)) - .join(', ') - ); - }), - registerSystemFn(jail) - ]); - - // Run code - const fn = await context.eval(getFnCode(code), { reference: true, timeout: 10000 }); - - try { - // Get result and parse - const value = await fn.apply(undefined, [], { result: { promise: true } }); - const result = JSON.parse(value.toLocaleString()); - - // release memory - context.release(); - isolate.dispose(); - - if (result.ERROR) { - return Promise.reject(result.ERROR); - } - - return { - codeReturn: result, - log: logData.join('\n') - }; - } catch (error) { - context.release(); - isolate.dispose(); - return Promise.reject('Not an invalid response.You must return an object'); - } - } catch (err) { - console.log(err); - - context.release(); - isolate.dispose(); - return Promise.reject(err); - } -}; - -const PythonScriptFileName = 'main.py'; -export const runPythonSandbox = async ({ - code, - variables = {} -}: RunCodeDto): Promise => { - // Validate input parameters - if (!code || typeof code !== 'string' || !code.trim()) { - return Promise.reject('Code cannot be empty'); - } - - // Ensure variables is an object - if (variables === null || variables === undefined) { - variables = {}; - } - if (typeof variables !== 'object' || Array.isArray(variables)) { - return Promise.reject('Variables must be an object'); - } - - const tempDir = await mkdtemp(join(tmpdir(), 'python_script_tmp_')); - const dataJson = JSON.stringify({ code, variables, tempDir }); - const dataBase64 = Buffer.from(dataJson).toString('base64'); - const mainCallCode = ` -import json -import base64 -data = json.loads(base64.b64decode('${dataBase64}').decode('utf-8')) -res = run_pythonCode(data) -print(json.dumps(res)) -`; - - const fullCode = [pythonScript, mainCallCode].filter(Boolean).join('\n'); - const { path: tempFilePath, cleanup } = await createTempFile(tempDir, fullCode); - const pythonProcess = spawn('python3', ['-u', tempFilePath]); - - const stdoutChunks: string[] = []; - const stderrChunks: string[] = []; - - pythonProcess.stdout.on('data', (data) => stdoutChunks.push(data.toString())); - pythonProcess.stderr.on('data', (data) => stderrChunks.push(data.toString())); - - const stdoutPromise = new Promise((resolve) => { - pythonProcess.on('close', (code) => { - if (code !== 0) { - resolve(JSON.stringify({ error: stderrChunks.join('') })); - } else { - resolve(stdoutChunks.join('')); - } - }); - }); - const stdout = await stdoutPromise.finally(() => { - cleanup(); - }); - - try { - const parsedOutput = JSON.parse(stdout); - if (parsedOutput.error) { - return Promise.reject(parsedOutput.error || 'Unknown error'); - } - return { codeReturn: parsedOutput, log: '' }; - } catch (err) { - if ( - stdout.includes('malformed node or string on line 1') || - stdout.includes('invalid syntax (, line 1)') - ) { - return Promise.reject(`The result should be a parsable variable, such as a list. ${stdout}`); - } else if (stdout.includes('Unexpected end of JSON input')) { - return Promise.reject(`Not allowed print or ${stdout}`); - } - return Promise.reject(`Run failed: ${err}`); - } -}; - -// write full code into a tmp file -async function createTempFile(tempFileDirPath: string, context: string) { - const tempFilePath = join(tempFileDirPath, PythonScriptFileName); - - try { - await writeFile(tempFilePath, context); - return { - path: tempFilePath, - cleanup: () => { - rmSync(tempFileDirPath, { - recursive: true, - force: true - }); - } - }; - } catch (err) { - return Promise.reject(`write file err: ${err}`); - } -} diff --git a/projects/sandbox/src/types.ts b/projects/sandbox/src/types.ts new file mode 100644 index 0000000000..dc73f561f6 --- /dev/null +++ b/projects/sandbox/src/types.ts @@ -0,0 +1,15 @@ +/** 执行请求参数 */ +export type ExecuteOptions = { + code: string; + variables: Record; +}; + +/** 执行结果 */ +export type ExecuteResult = { + success: boolean; + data?: { + codeReturn: any; + log: string; + }; + message?: string; +}; diff --git a/projects/sandbox/src/utils.ts b/projects/sandbox/src/utils.ts deleted file mode 100644 index 2247dedcfa..0000000000 --- a/projects/sandbox/src/utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const replaceSensitiveText = (text: string) => { - // 1. http link - text = text.replace(/(?<=https?:\/\/)[^\s]+/g, 'xxx'); - // 2. nx-xxx 全部替换成xxx - text = text.replace(/ns-[\w-]+/g, 'xxx'); - - return text; -}; - -export const getErrText = (err: any, def = '') => { - const msg: string = typeof err === 'string' ? err : err?.message ?? def; - msg && console.log('error =>', msg); - return replaceSensitiveText(msg); -}; diff --git a/projects/sandbox/src/utils/index.ts b/projects/sandbox/src/utils/index.ts new file mode 100644 index 0000000000..e660a00bea --- /dev/null +++ b/projects/sandbox/src/utils/index.ts @@ -0,0 +1,21 @@ +export const getErrText = (err: any, def = ''): any => { + const msg: string = + typeof err === 'string' + ? err || def + : err?.response?.data?.message || + err?.response?.message || + err?.message || + err?.response?.data?.msg || + err?.response?.msg || + err?.msg || + err?.error || + def; + + // Axios special + if (err?.errors && Array.isArray(err.errors) && err.errors.length > 0) { + return err.errors[0].message; + } + + // msg && console.log('error =>', msg); + return msg; +}; diff --git a/projects/sandbox/src/utils/semaphore.ts b/projects/sandbox/src/utils/semaphore.ts new file mode 100644 index 0000000000..05a15ba62f --- /dev/null +++ b/projects/sandbox/src/utils/semaphore.ts @@ -0,0 +1,36 @@ +/** + * Semaphore - 简单信号量,控制最大并发数 + * + * 超出并发上限的请求排队等待,避免子进程数爆炸。 + */ +export class Semaphore { + private current = 0; + private queue: (() => void)[] = []; + + constructor(private readonly max: number) {} + + /** 获取许可,超出上限则排队 */ + acquire(): Promise { + if (this.current < this.max) { + this.current++; + return Promise.resolve(); + } + return new Promise((resolve) => { + this.queue.push(resolve); + }); + } + + /** 释放许可,唤醒队列中下一个 */ + release(): void { + const next = this.queue.shift(); + if (next) { + next(); + } else { + this.current--; + } + } + + get stats() { + return { current: this.current, queued: this.queue.length, max: this.max }; + } +} diff --git a/projects/sandbox/test/benchmark/bench-sandbox-python.sh b/projects/sandbox/test/benchmark/bench-sandbox-python.sh new file mode 100644 index 0000000000..6428f96943 --- /dev/null +++ b/projects/sandbox/test/benchmark/bench-sandbox-python.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# FastGPT Sandbox Python 压测脚本 +# 用法: SANDBOX_TOKEN=xxx ./bench-sandbox-python.sh +# SANDBOX_URL=http://host:3000 SANDBOX_TOKEN=xxx ./bench-sandbox-python.sh + +set -eo pipefail + +BASE="${SANDBOX_URL:-http://localhost:3000}" +TOKEN="${SANDBOX_TOKEN:-}" +DURATION="${BENCH_DURATION:-10}" + +# 构建 npx autocannon 认证参数 +AUTH_ARGS="" +if [ -n "$TOKEN" ]; then + AUTH_ARGS="-H Authorization=Bearer%20${TOKEN}" +fi + +echo "========================================" +echo " FastGPT Sandbox Python 压测" +echo " 服务: $BASE" +echo " 认证: $([ -n "$TOKEN" ] && echo '已配置' || echo '未配置')" +echo "========================================" + +# 健康检查 +HEALTH=$(curl -sf "$BASE/health" 2>/dev/null) || { + echo "错误: Sandbox 服务未启动 ($BASE/health)" + exit 1 +} +echo "健康状态: $(echo "$HEALTH" | grep -o '"status":"[^"]*"' || echo "$HEALTH")" + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "▶ 测试 1: 普通函数 (简单计算)" +echo " 并发: 50 持续: ${DURATION}s" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +npx autocannon -c 50 -d "$DURATION" -m POST \ + -H "Content-Type=application/json" \ + $AUTH_ARGS \ + -b '{"code":"def main(variables):\n return 1 + 1","variables":{}}' \ + "${BASE}/sandbox/python" + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "▶ 测试 2: 长时间IO函数 (sleep 500ms)" +echo " 并发: 50 持续: ${DURATION}s" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +npx autocannon -c 50 -d "$DURATION" -m POST \ + -H "Content-Type=application/json" \ + $AUTH_ARGS \ + -b '{"code":"import time\ndef main(variables):\n time.sleep(0.5)\n return \"done\"","variables":{}}' \ + "${BASE}/sandbox/python" + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "▶ 测试 3: 高CPU函数 (大量计算)" +echo " 并发: 10 持续: ${DURATION}s" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +npx autocannon -c 10 -d "$DURATION" -m POST \ + -H "Content-Type=application/json" \ + $AUTH_ARGS \ + -b '{"code":"import math\ndef main(variables):\n s=0\n for i in range(5000000):\n s+=math.sqrt(i)\n return s","variables":{}}' \ + "${BASE}/sandbox/python" + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "▶ 测试 4: 高内存函数 (大列表)" +echo " 并发: 10 持续: ${DURATION}s" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +npx autocannon -c 10 -d "$DURATION" -m POST \ + -H "Content-Type=application/json" \ + $AUTH_ARGS \ + -b '{"code":"def main(variables):\n arr = [i*i for i in range(2000000)]\n return len(arr)","variables":{}}' \ + "${BASE}/sandbox/python" + +echo "" +echo "========================================" +echo " 压测完成!" +echo "========================================" diff --git a/projects/sandbox/test/benchmark/bench-sandbox.sh b/projects/sandbox/test/benchmark/bench-sandbox.sh new file mode 100644 index 0000000000..83e148f5f1 --- /dev/null +++ b/projects/sandbox/test/benchmark/bench-sandbox.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# FastGPT Sandbox JS 压测脚本 +# 用法: SANDBOX_TOKEN=xxx ./bench-sandbox.sh +# SANDBOX_URL=http://host:3000 SANDBOX_TOKEN=xxx ./bench-sandbox.sh + +set -eo pipefail + +BASE="${SANDBOX_URL:-http://localhost:3000}" +TOKEN="${SANDBOX_TOKEN:-}" +DURATION="${BENCH_DURATION:-10}" + +# 构建 npx autocannon 认证参数 +AUTH_ARGS="" +if [ -n "$TOKEN" ]; then + AUTH_ARGS="-H Authorization=Bearer%20${TOKEN}" +fi + +echo "========================================" +echo " FastGPT Sandbox JS 压测" +echo " 服务: $BASE" +echo " 认证: $([ -n "$TOKEN" ] && echo '已配置' || echo '未配置')" +echo "========================================" + +# 健康检查 +HEALTH=$(curl -sf "$BASE/health" 2>/dev/null) || { + echo "错误: Sandbox 服务未启动 ($BASE/health)" + exit 1 +} +echo "健康状态: $(echo "$HEALTH" | grep -o '"status":"[^"]*"' || echo "$HEALTH")" + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "▶ 测试 1: 普通函数 (简单计算)" +echo " 并发: 50 持续: ${DURATION}s" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +npx autocannon -c 50 -d "$DURATION" -m POST \ + -H "Content-Type=application/json" \ + $AUTH_ARGS \ + -b '{"code":"function main() { return 1 + 1; }","variables":{}}' \ + "${BASE}/sandbox/js" + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "▶ 测试 2: 长时间IO函数 (delay 500ms)" +echo " 并发: 50 持续: ${DURATION}s" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +npx autocannon -c 50 -d "$DURATION" -m POST \ + -H "Content-Type=application/json" \ + $AUTH_ARGS \ + -b '{"code":"async function main() { await delay(500); return \"done\"; }","variables":{}}' \ + "${BASE}/sandbox/js" + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "▶ 测试 3: 高CPU函数 (大量计算)" +echo " 并发: 10 持续: ${DURATION}s" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +npx autocannon -c 10 -d "$DURATION" -m POST \ + -H "Content-Type=application/json" \ + $AUTH_ARGS \ + -b '{"code":"function main() { let s=0; for(let i=0;i<50000000;i++) s+=Math.sqrt(i); return s; }","variables":{}}' \ + "${BASE}/sandbox/js" + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "▶ 测试 4: 高内存函数 (分配大数组)" +echo " 并发: 10 持续: ${DURATION}s" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +npx autocannon -c 10 -d "$DURATION" -m POST \ + -H "Content-Type=application/json" \ + $AUTH_ARGS \ + -b '{"code":"function main() { const arr = new Array(5000000).fill(0).map((_,i)=>i*i); return arr.length; }","variables":{}}' \ + "${BASE}/sandbox/js" + +echo "" +echo "========================================" +echo " 压测完成!" +echo "========================================" diff --git a/projects/sandbox/test/compat/legacy-js.test.ts b/projects/sandbox/test/compat/legacy-js.test.ts new file mode 100644 index 0000000000..51b1d0d167 --- /dev/null +++ b/projects/sandbox/test/compat/legacy-js.test.ts @@ -0,0 +1,274 @@ +/** + * 旧版 JS 代码兼容性测试 + * + * 验证新沙盒能正确执行旧版 FastGPT 生成的 JS 代码写法,包括: + * - main(variables, {}) 两参数调用 + * - 全局 delay() / countToken() / strToBase64() / createHmac() + * - console.log 捕获 + * - require 白名单模块(lodash, dayjs) + * - 各种 main 函数签名 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ProcessPool } from '../../src/pool/process-pool'; + +let pool: ProcessPool; + +beforeAll(async () => { + pool = new ProcessPool(1); + await pool.init(); +}); + +afterAll(async () => { + await pool.shutdown(); +}); + +describe('旧版 JS 兼容性', () => { + // ===== main 函数签名兼容 ===== + + it('main(variables, {}) 两参数写法', async () => { + const result = await pool.execute({ + code: `async function main(variables, extra) { + return { name: variables.name, hasExtra: extra !== undefined }; + }`, + variables: { name: 'FastGPT' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.name).toBe('FastGPT'); + }); + + it('main({变量解构}) 写法', async () => { + const result = await pool.execute({ + code: `async function main({ name, age }) { + return { name, age }; + }`, + variables: { name: 'test', age: 18 } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn).toEqual({ name: 'test', age: 18 }); + }); + + it('main() 无参数写法 — 通过闭包访问 variables', async () => { + const result = await pool.execute({ + code: `async function main() { + return { name: variables.name }; + }`, + variables: { name: 'closure-test' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.name).toBe('closure-test'); + }); + + it('非 async main 函数', async () => { + const result = await pool.execute({ + code: `function main(v) { + return { sum: v.a + v.b }; + }`, + variables: { a: 10, b: 20 } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.sum).toBe(30); + }); + + // ===== 全局内置函数兼容 ===== + + it('全局 delay() 函数', async () => { + const result = await pool.execute({ + code: `async function main() { + const start = Date.now(); + await delay(100); + return { elapsed: Date.now() - start }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.elapsed).toBeGreaterThanOrEqual(80); + }); + + it('全局 countToken() 函数', async () => { + const result = await pool.execute({ + code: `async function main() { + return { count: countToken("hello world test string") }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.count).toBeGreaterThan(0); + }); + + it('全局 strToBase64() 函数', async () => { + const result = await pool.execute({ + code: `async function main() { + return { b64: strToBase64("hello") }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.b64).toBe(Buffer.from('hello').toString('base64')); + }); + + it('全局 strToBase64() 带 prefix', async () => { + const result = await pool.execute({ + code: `async function main() { + return { b64: strToBase64("hello", "data:text/plain;base64,") }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.b64).toBe( + 'data:text/plain;base64,' + Buffer.from('hello').toString('base64') + ); + }); + + it('全局 createHmac() 函数', async () => { + const result = await pool.execute({ + code: `async function main() { + const r = createHmac("sha256", "my-secret"); + return { ts: r.timestamp, sign: r.sign }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.ts).toBeTruthy(); + expect(result.data?.codeReturn.sign).toBeTruthy(); + }); + + // ===== console.log 兼容 ===== + + it('console.log 被捕获到 log 字段', async () => { + const result = await pool.execute({ + code: `async function main() { + console.log("step 1"); + console.log("step 2", { key: "value" }); + return { done: true }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.log).toContain('step 1'); + expect(result.data?.log).toContain('step 2'); + expect(result.data?.log).toContain('key'); + }); + + // ===== require 白名单模块 ===== + + it('require lodash 正常使用', async () => { + const result = await pool.execute({ + code: `async function main(v) { + const _ = require('lodash'); + return { result: _.sum(v.arr) }; + }`, + variables: { arr: [1, 2, 3, 4, 5] } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.result).toBe(15); + }); + + it('require dayjs 正常使用', async () => { + const result = await pool.execute({ + code: `async function main() { + const dayjs = require('dayjs'); + return { valid: dayjs('2024-01-01').isValid() }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.valid).toBe(true); + }); + + it('require qs 正常使用', async () => { + const result = await pool.execute({ + code: `async function main() { + const qs = require('qs'); + return { str: qs.stringify({ a: 1, b: 2 }) }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.str).toBe('a=1&b=2'); + }); + + // ===== 典型旧版代码模式 ===== + + it('旧版典型写法:HTTP 请求签名', async () => { + const result = await pool.execute({ + code: `async function main(variables, extra) { + const hmacResult = createHmac("sha256", variables.secret); + const token = strToBase64(variables.secret); + return { + timestamp: hmacResult.timestamp, + sign: hmacResult.sign, + token: token + }; + }`, + variables: { secret: 'my-webhook-secret' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.timestamp).toBeTruthy(); + expect(result.data?.codeReturn.sign).toBeTruthy(); + expect(result.data?.codeReturn.token).toBeTruthy(); + }); + + it('旧版典型写法:数据处理 + lodash', async () => { + const result = await pool.execute({ + code: `async function main(variables) { + const _ = require('lodash'); + const items = variables.data; + const grouped = _.groupBy(items, 'type'); + const counts = _.mapValues(grouped, arr => arr.length); + return counts; + }`, + variables: { + data: [ + { type: 'a', val: 1 }, + { type: 'b', val: 2 }, + { type: 'a', val: 3 } + ] + } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn).toEqual({ a: 2, b: 1 }); + }); + + it('旧版典型写法:字符串处理 + delay', async () => { + const result = await pool.execute({ + code: `async function main(variables) { + await delay(50); + const count = countToken(variables.text); + const encoded = strToBase64(variables.text); + return { count, encoded, length: variables.text.length }; + }`, + variables: { text: 'Hello FastGPT sandbox!' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.count).toBeGreaterThan(0); + expect(result.data?.codeReturn.encoded).toBeTruthy(); + expect(result.data?.codeReturn.length).toBe(22); + }); + + it('返回非对象值(旧版可能返回数组)', async () => { + const result = await pool.execute({ + code: `async function main(v) { + return [v.a, v.b, v.a + v.b]; + }`, + variables: { a: 1, b: 2 } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn).toEqual([1, 2, 3]); + }); + + it('返回嵌套复杂对象', async () => { + const result = await pool.execute({ + code: `async function main(v) { + return { + user: { name: v.name, tags: ['admin', 'user'] }, + meta: { count: 42, active: true } + }; + }`, + variables: { name: 'test' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.user.name).toBe('test'); + expect(result.data?.codeReturn.user.tags).toEqual(['admin', 'user']); + expect(result.data?.codeReturn.meta.count).toBe(42); + }); +}); diff --git a/projects/sandbox/test/compat/legacy-python.test.ts b/projects/sandbox/test/compat/legacy-python.test.ts new file mode 100644 index 0000000000..ea9996d12c --- /dev/null +++ b/projects/sandbox/test/compat/legacy-python.test.ts @@ -0,0 +1,297 @@ +/** + * 旧版 Python 代码兼容性测试 + * + * 验证新沙盒能正确执行旧版 FastGPT 生成的 Python 代码写法,包括: + * - main() 无参数(通过全局变量访问) + * - main(variables) 单参数字典 + * - main(a, b, c) 多参数展开 + * - print 被收集到 log(旧版直接移除) + * - 危险模块拦截 + * - 各种返回值类型 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { PythonProcessPool } from '../../src/pool/python-process-pool'; + +let pool: PythonProcessPool; + +beforeAll(async () => { + pool = new PythonProcessPool(1); + await pool.init(); +}); + +afterAll(async () => { + await pool.shutdown(); +}); + +describe('旧版 Python 兼容性', () => { + // ===== main 函数签名兼容 ===== + + it('main(variables) 单参数字典写法', async () => { + const result = await pool.execute({ + code: `def main(variables): + return {"name": variables["name"], "age": variables["age"]}`, + variables: { name: 'FastGPT', age: 3 } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn).toEqual({ name: 'FastGPT', age: 3 }); + }); + + it('main(a, b) 多参数展开写法', async () => { + const result = await pool.execute({ + code: `def main(a, b): + return {"sum": a + b}`, + variables: { a: 10, b: 20 } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.sum).toBe(30); + }); + + it('main(a, b, c) 三参数展开', async () => { + const result = await pool.execute({ + code: `def main(a, b, c): + return {"result": a * b + c}`, + variables: { a: 3, b: 4, c: 5 } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.result).toBe(17); + }); + + it('main() 无参数写法', async () => { + const result = await pool.execute({ + code: `def main(): + return {"ok": True}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.ok).toBe(true); + }); + + it('旧版写法:main 外部直接访问全局变量', async () => { + const result = await pool.execute({ + code: `prefix = name + "_suffix" +def main(name, age): + return {"result": prefix, "name": name, "age": age}`, + variables: { name: 'hello', age: 18 } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.result).toBe('hello_suffix'); + expect(result.data?.codeReturn.name).toBe('hello'); + expect(result.data?.codeReturn.age).toBe(18); + }); + + it('旧版写法:无参 main 通过全局变量访问', async () => { + const result = await pool.execute({ + code: `def main(): + return {"name": name, "age": age}`, + variables: { name: 'test', age: 25 } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn).toEqual({ name: 'test', age: 25 }); + }); + + it('main 带默认参数', async () => { + const result = await pool.execute({ + code: `def main(name, greeting="Hello"): + return {"msg": f"{greeting}, {name}!"}`, + variables: { name: 'World' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.msg).toBe('Hello, World!'); + }); + + // ===== 返回值类型兼容 ===== + + it('返回列表(旧版常见)', async () => { + const result = await pool.execute({ + code: `def main(variables): + return [variables["a"], variables["b"], variables["a"] + variables["b"]]`, + variables: { a: 1, b: 2 } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn).toEqual([1, 2, 3]); + }); + + it('返回嵌套字典', async () => { + const result = await pool.execute({ + code: `def main(variables): + return { + "user": {"name": variables["name"], "tags": ["admin"]}, + "count": 42 + }`, + variables: { name: 'test' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.user.name).toBe('test'); + expect(result.data?.codeReturn.count).toBe(42); + }); + + it('返回布尔值和 None 转换', async () => { + const result = await pool.execute({ + code: `def main(variables): + return {"active": True, "deleted": False, "extra": None}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.active).toBe(true); + expect(result.data?.codeReturn.deleted).toBe(false); + expect(result.data?.codeReturn.extra).toBeNull(); + }); + + // ===== print 行为 ===== + + it('print 输出收集到 log(不影响返回值)', async () => { + const result = await pool.execute({ + code: `def main(variables): + print("debug step 1") + print("processing", variables["name"]) + return {"done": True}`, + variables: { name: 'test' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.done).toBe(true); + expect(result.data?.log).toContain('debug step 1'); + expect(result.data?.log).toContain('processing'); + }); + + // ===== 危险模块拦截 ===== + + it('import os 被拦截', async () => { + const result = await pool.execute({ + code: `import os +def main(): + return {"cwd": os.getcwd()}`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toContain('os'); + }); + + it('import subprocess 被拦截', async () => { + const result = await pool.execute({ + code: `import subprocess +def main(): + return {"out": subprocess.check_output(["ls"])}`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toContain('subprocess'); + }); + + it('from sys import path 被拦截', async () => { + const result = await pool.execute({ + code: `from sys import path +def main(): + return {"path": path}`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toContain('sys'); + }); + + // ===== 安全模块允许 ===== + + it('import json 允许', async () => { + const result = await pool.execute({ + code: `import json +def main(variables): + data = json.dumps(variables) + parsed = json.loads(data) + return {"parsed": parsed}`, + variables: { key: 'value' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.parsed).toEqual({ key: 'value' }); + }); + + it('import math 允许', async () => { + const result = await pool.execute({ + code: `import math +def main(variables): + return {"sqrt": math.sqrt(variables["n"]), "pi": round(math.pi, 4)}`, + variables: { n: 16 } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.sqrt).toBe(4); + expect(result.data?.codeReturn.pi).toBe(3.1416); + }); + + it('import re 允许', async () => { + const result = await pool.execute({ + code: `import re +def main(variables): + matches = re.findall(r'\\d+', variables["text"]) + return {"numbers": matches}`, + variables: { text: 'abc123def456' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.numbers).toEqual(['123', '456']); + }); + + // ===== 典型旧版代码模式 ===== + + it('旧版典型写法:数据过滤', async () => { + const result = await pool.execute({ + code: `def main(variables): + items = variables["items"] + filtered = [x for x in items if x["score"] >= 60] + return {"passed": len(filtered), "total": len(items)}`, + variables: { + items: [ + { name: 'A', score: 80 }, + { name: 'B', score: 45 }, + { name: 'C', score: 90 }, + { name: 'D', score: 55 } + ] + } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn).toEqual({ passed: 2, total: 4 }); + }); + + it('旧版典型写法:字符串处理', async () => { + const result = await pool.execute({ + code: `def main(variables): + text = variables["text"] + words = text.split() + return { + "word_count": len(words), + "upper": text.upper(), + "reversed": text[::-1] + }`, + variables: { text: 'hello world' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.word_count).toBe(2); + expect(result.data?.codeReturn.upper).toBe('HELLO WORLD'); + expect(result.data?.codeReturn.reversed).toBe('dlrow olleh'); + }); + + it('旧版典型写法:日期处理', async () => { + const result = await pool.execute({ + code: `from datetime import datetime, timedelta +def main(variables): + dt = datetime.strptime(variables["date"], "%Y-%m-%d") + next_day = dt + timedelta(days=1) + return {"next": next_day.strftime("%Y-%m-%d"), "weekday": dt.strftime("%A")}`, + variables: { date: '2024-01-01' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.next).toBe('2024-01-02'); + expect(result.data?.codeReturn.weekday).toBe('Monday'); + }); + + it('旧版典型写法:辅助函数 + main', async () => { + const result = await pool.execute({ + code: `def calculate_tax(amount, rate): + return round(amount * rate, 2) + +def main(variables): + amount = variables["amount"] + tax = calculate_tax(amount, 0.13) + return {"amount": amount, "tax": tax, "total": amount + tax}`, + variables: { amount: 100 } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn).toEqual({ amount: 100, tax: 13, total: 113 }); + }); +}); diff --git a/projects/sandbox/test/integration/api.test.ts b/projects/sandbox/test/integration/api.test.ts new file mode 100644 index 0000000000..64e1a6a7f1 --- /dev/null +++ b/projects/sandbox/test/integration/api.test.ts @@ -0,0 +1,286 @@ +/** + * API 测试 - 使用 app.request() 直接测试 Hono 路由 + * 无需启动服务或配置 SANDBOX_URL + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { app, poolReady } from '../../src/index'; +import { config } from '../../src/config'; + +/** 构造请求 headers,自动带上 auth(如果配置了 token) */ +function headers(extra: Record = {}): Record { + const h: Record = { ...extra }; + if (config.token) { + h['Authorization'] = `Bearer ${config.token}`; + } + return h; +} + +describe('API Routes', () => { + beforeAll(async () => { + await poolReady; + }, 30000); + + // ===== Health ===== + it('GET /health 返回 200', async () => { + const res = await app.request('/health'); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.status).toBe('ok'); + }); + + // ===== JS ===== + it('POST /sandbox/js 正常执行', async () => { + const res = await app.request('/sandbox/js', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + code: 'async function main(v) { return { hello: v.name } }', + variables: { name: 'world' } + }) + }); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.data.codeReturn.hello).toBe('world'); + }); + + it('POST /sandbox/js 忽略额外参数', async () => { + const res = await app.request('/sandbox/js', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + code: 'async function main(v) { return { ok: true } }', + variables: {} + }) + }); + const data = await res.json(); + expect(data.success).toBe(true); + }); + + it('POST /sandbox/js 安全拦截', async () => { + const res = await app.request('/sandbox/js', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + code: 'async function main() { require("child_process"); return {} }', + variables: {} + }) + }); + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.message).toContain('not allowed'); + }); + + // ===== Python ===== + it('POST /sandbox/python 正常执行', async () => { + const res = await app.request('/sandbox/python', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + code: 'def main(variables):\n return {"hello": variables["name"]}', + variables: { name: 'world' } + }) + }); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.data.codeReturn.hello).toBe('world'); + }); + + it('POST /sandbox/python 安全拦截', async () => { + const res = await app.request('/sandbox/python', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + code: 'import os\ndef main(v):\n return {}', + variables: {} + }) + }); + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.message).toContain('not in the allowlist'); + }); + + // ===== Modules ===== + it('GET /sandbox/modules 返回可用模块列表', async () => { + const res = await app.request('/sandbox/modules', { + headers: headers() + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.data.js).toEqual(config.jsAllowedModules); + expect(data.data.builtinGlobals).toContain('SystemHelper.httpRequest'); + expect(data.data.python).toEqual(config.pythonAllowedModules); + }); +}); + +// ===== 错误处理安全 ===== +describe('API 错误处理安全', () => { + beforeAll(async () => { + await poolReady; + }, 30000); + + it('JS 执行异常不泄露堆栈', async () => { + const res = await app.request('/sandbox/js', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + code: 'async function main() { null.x; }', + variables: {} + }) + }); + const data = await res.json(); + expect(data.success).toBe(false); + // 错误信息不应包含宿主进程的真实文件路径(如 node_modules、/src/pool/) + const msg = data.message || ''; + expect(msg).not.toContain('node_modules'); + expect(msg).not.toContain('/src/pool/'); + expect(msg).not.toContain('process-pool'); + }); + + it('无效 JSON body 返回 400', async () => { + const res = await app.request('/sandbox/js', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: 'this is not json' + }); + // Hono 解析 JSON 失败会抛异常,被 catch 捕获返回报错 + // 或者 zod 校验失败返回 400 + expect([400, 200]).toContain(res.status); + const data = await res.json(); + if (res.status === 400) { + expect(data.success).toBe(false); + expect(data.message).toMatch(/invalid|validation/i); + } else { + // catch 分支 + expect(data.success).toBe(false); + console.log(data, 123213213); + expect(data.message).toContain('is not valid JSON'); + } + }); +}); + +// ===== Zod 校验失败(有效 JSON 但 schema 不匹配) ===== +describe('API Zod 校验失败', () => { + beforeAll(async () => { + await poolReady; + }, 30000); + + it('JS: code 为数字返回 400', async () => { + const res = await app.request('/sandbox/js', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ code: 123, variables: {} }) + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.message).toMatch(/Invalid request/i); + }); + + it('JS: 缺少 code 字段返回 400', async () => { + const res = await app.request('/sandbox/js', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ variables: {} }) + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.message).toMatch(/Invalid request/i); + }); + + it('JS: code 为空字符串返回 400', async () => { + const res = await app.request('/sandbox/js', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ code: '', variables: {} }) + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.success).toBe(false); + }); + + it('Python: code 为数字返回 400', async () => { + const res = await app.request('/sandbox/python', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ code: 123, variables: {} }) + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.message).toMatch(/Invalid request/i); + }); + + it('Python: 缺少 code 字段返回 400', async () => { + const res = await app.request('/sandbox/python', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ variables: {} }) + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.message).toMatch(/Invalid request/i); + }); + + it('Python: 无效 JSON body 返回错误', async () => { + const res = await app.request('/sandbox/python', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: 'this is not json' + }); + const data = await res.json(); + expect(data.success).toBe(false); + }); +}); + +/** + * Auth 测试 + * 默认 SANDBOX_TOKEN 为空,auth 中间件不启用。 + * 设置 SANDBOX_TOKEN=xxx 运行可测试鉴权逻辑。 + */ +describe.skipIf(!config.token)('API Auth (requires SANDBOX_TOKEN)', () => { + it('无 Token 返回 401', async () => { + const res = await app.request('/sandbox/js', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code: 'async function main() { return {} }', + variables: {} + }) + }); + expect(res.status).toBe(401); + }); + + it('错误 Token 返回 401', async () => { + const res = await app.request('/sandbox/js', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer wrong-token' + }, + body: JSON.stringify({ + code: 'async function main() { return {} }', + variables: {} + }) + }); + expect(res.status).toBe(401); + }); + + it('正确 Token 返回 200', async () => { + const res = await app.request('/sandbox/js', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.token}` + }, + body: JSON.stringify({ + code: 'async function main() { return { ok: true } }', + variables: {} + }) + }); + const data = await res.json(); + expect(data.success).toBe(true); + }); +}); diff --git a/projects/sandbox/test/integration/functional.test.ts b/projects/sandbox/test/integration/functional.test.ts new file mode 100644 index 0000000000..1505a1e87e --- /dev/null +++ b/projects/sandbox/test/integration/functional.test.ts @@ -0,0 +1,693 @@ +/** + * 集成测试套件 - 黑盒功能测试 + * + * 测试矩阵:输入代码 + 变量 → 预期输出 + * 覆盖真实使用场景,不关心内部实现 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ProcessPool } from '../../src/pool/process-pool'; +import { PythonProcessPool } from '../../src/pool/python-process-pool'; + +// ============================================================ +// 测试用例矩阵类型 +// ============================================================ +interface TestCase { + name: string; + code: string; + variables?: Record; + expect: { + success: boolean; + codeReturn?: any; // 精确匹配 + codeReturnMatch?: Record; // 部分匹配 + errorMatch?: RegExp; // 错误信息匹配 + hasLog?: boolean; // 是否有日志输出 + }; +} + +function runMatrix(getPool: () => ProcessPool | PythonProcessPool, cases: TestCase[]) { + for (const tc of cases) { + it(tc.name, async () => { + const result = await getPool().execute({ + code: tc.code, + variables: tc.variables || {} + }); + + expect(result.success).toBe(tc.expect.success); + + if (tc.expect.codeReturn !== undefined) { + expect(result.data?.codeReturn).toEqual(tc.expect.codeReturn); + } + if (tc.expect.codeReturnMatch) { + for (const [key, val] of Object.entries(tc.expect.codeReturnMatch)) { + expect(result.data?.codeReturn?.[key]).toEqual(val); + } + } + if (tc.expect.errorMatch) { + expect(result.message).toMatch(tc.expect.errorMatch); + } + if (tc.expect.hasLog) { + expect(result.data?.log).toBeTruthy(); + expect(result.data!.log!.length).toBeGreaterThan(0); + } + }); + } +} + +// ============================================================ +// JS 功能测试矩阵 +// ============================================================ +describe('JS 功能测试', () => { + let pool: ProcessPool; + beforeAll(async () => { + pool = new ProcessPool(1); + await pool.init(); + }); + afterAll(async () => { + await pool.shutdown(); + }); + + // --- 基础运算 --- + describe('基础运算', () => { + runMatrix( + () => pool, + [ + { + name: '返回简单对象', + code: `async function main() { return { hello: 'world' }; }`, + expect: { success: true, codeReturn: { hello: 'world' } } + }, + { + name: '数学运算', + code: `async function main() { return { sum: 1 + 2, product: 3 * 4, division: 10 / 3 }; }`, + expect: { success: true, codeReturnMatch: { sum: 3, product: 12 } } + }, + { + name: '字符串操作', + code: `async function main() { + const s = 'Hello, World!'; + return { upper: s.toUpperCase(), len: s.length, includes: s.includes('World') }; + }`, + expect: { success: true, codeReturn: { upper: 'HELLO, WORLD!', len: 13, includes: true } } + }, + { + name: '数组操作', + code: `async function main() { + const arr = [3, 1, 4, 1, 5, 9]; + return { sorted: [...arr].sort((a,b) => a-b), sum: arr.reduce((a,b) => a+b, 0), len: arr.length }; + }`, + expect: { success: true, codeReturn: { sorted: [1, 1, 3, 4, 5, 9], sum: 23, len: 6 } } + }, + { + name: 'JSON 解析与序列化', + code: `async function main() { + const obj = { a: 1, b: [2, 3] }; + const json = JSON.stringify(obj); + const parsed = JSON.parse(json); + return { json, equal: JSON.stringify(parsed) === json }; + }`, + expect: { success: true, codeReturn: { json: '{"a":1,"b":[2,3]}', equal: true } } + }, + { + name: '正则表达式', + code: `async function main() { + const text = 'Email: test@example.com, Phone: 123-456-7890'; + const email = text.match(/[\\w.]+@[\\w.]+/)?.[0]; + const phone = text.match(/\\d{3}-\\d{3}-\\d{4}/)?.[0]; + return { email, phone }; + }`, + expect: { + success: true, + codeReturn: { email: 'test@example.com', phone: '123-456-7890' } + } + }, + { + name: 'Date 操作', + code: `async function main() { + const d = new Date('2024-01-15T12:00:00Z'); + return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() }; + }`, + expect: { success: true, codeReturn: { year: 2024, month: 1, day: 15 } } + }, + { + name: 'Promise.all 并发', + code: `async function main() { + const results = await Promise.all([ + Promise.resolve(1), + Promise.resolve(2), + Promise.resolve(3) + ]); + return { results, sum: results.reduce((a,b) => a+b, 0) }; + }`, + expect: { success: true, codeReturn: { results: [1, 2, 3], sum: 6 } } + }, + { + name: 'Map 和 Set', + code: `async function main() { + const m = new Map([['a', 1], ['b', 2]]); + const s = new Set([1, 2, 2, 3, 3]); + return { mapSize: m.size, mapGet: m.get('b'), setSize: s.size }; + }`, + expect: { success: true, codeReturn: { mapSize: 2, mapGet: 2, setSize: 3 } } + } + ] + ); + }); + + // --- 变量传递 --- + describe('变量传递', () => { + runMatrix( + () => pool, + [ + { + name: '接收字符串变量', + code: `async function main(v) { return { greeting: 'Hello, ' + v.name + '!' }; }`, + variables: { name: 'Alice' }, + expect: { success: true, codeReturn: { greeting: 'Hello, Alice!' } } + }, + { + name: '接收数字变量', + code: `async function main(v) { return { doubled: v.num * 2 }; }`, + variables: { num: 21 }, + expect: { success: true, codeReturn: { doubled: 42 } } + }, + { + name: '接收复杂对象变量', + code: `async function main(v) { + const items = JSON.parse(v.items); + return { count: items.length, first: items[0] }; + }`, + variables: { items: '[{"id":1,"name":"foo"},{"id":2,"name":"bar"}]' }, + expect: { success: true, codeReturn: { count: 2, first: { id: 1, name: 'foo' } } } + }, + { + name: '空变量对象', + code: `async function main(v) { return { keys: Object.keys(v || {}).length }; }`, + variables: {}, + expect: { success: true, codeReturn: { keys: 0 } } + }, + { + name: '多个变量', + code: `async function main(v) { return { result: v.a + ' ' + v.b + ' ' + v.c }; }`, + variables: { a: 'hello', b: 'beautiful', c: 'world' }, + expect: { success: true, codeReturn: { result: 'hello beautiful world' } } + } + ] + ); + }); + + // --- console.log 日志 --- + describe('日志输出', () => { + runMatrix( + () => pool, + [ + { + name: 'console.log 被捕获', + code: `async function main() { console.log('debug info'); return { done: true }; }`, + expect: { success: true, codeReturn: { done: true }, hasLog: true } + } + ] + ); + }); + + // --- 白名单模块 --- + describe('白名单模块', () => { + runMatrix( + () => pool, + [ + { + name: 'require crypto-js', + code: `async function main() { + const CryptoJS = require('crypto-js'); + const hash = CryptoJS.MD5('hello').toString(); + return { hash }; + }`, + expect: { success: true, codeReturnMatch: { hash: '5d41402abc4b2a76b9719d911017c592' } } + }, + { + name: 'require moment', + code: `async function main() { + const moment = require('moment'); + const d = moment('2024-01-15'); + return { formatted: d.format('YYYY/MM/DD') }; + }`, + expect: { success: true, codeReturn: { formatted: '2024/01/15' } } + }, + { + name: 'require lodash', + code: `async function main() { + const _ = require('lodash'); + return { chunk: _.chunk([1,2,3,4,5,6], 2) }; + }`, + expect: { + success: true, + codeReturn: { + chunk: [ + [1, 2], + [3, 4], + [5, 6] + ] + } + } + }, + { + name: 'require lodash groupBy', + code: `async function main({ items }) { + const _ = require('lodash'); + const grouped = _.groupBy(items, 'type'); + const counts = _.mapValues(grouped, arr => arr.length); + return counts; + }`, + variables: { + items: [ + { type: 'a', v: 1 }, + { type: 'b', v: 2 }, + { type: 'a', v: 3 }, + { type: 'c', v: 4 } + ] + }, + expect: { success: true, codeReturn: { a: 2, b: 1, c: 1 } } + }, + { + name: 'require dayjs', + code: `async function main() { + const dayjs = require('dayjs'); + const d = dayjs('2024-01-15'); + return { formatted: d.format('YYYY/MM/DD'), month: d.month() + 1 }; + }`, + expect: { success: true, codeReturn: { formatted: '2024/01/15', month: 1 } } + }, + { + name: 'require uuid', + code: `async function main() { + const { v4 } = require('uuid'); + const id = v4(); + return { valid: /^[0-9a-f-]{36}$/.test(id) }; + }`, + expect: { success: true, codeReturn: { valid: true } } + }, + { + name: 'require crypto-js AES 加解密', + code: `async function main() { + const CryptoJS = require('crypto-js'); + const encrypted = CryptoJS.AES.encrypt('hello', 'secret').toString(); + const decrypted = CryptoJS.AES.decrypt(encrypted, 'secret').toString(CryptoJS.enc.Utf8); + return { match: decrypted === 'hello' }; + }`, + expect: { success: true, codeReturn: { match: true } } + } + ] + ); + }); + + // --- 错误处理 --- + describe('错误处理', () => { + runMatrix( + () => pool, + [ + { + name: '语法错误', + code: `async function main() { return {{{ }`, + expect: { success: false } + }, + { + name: '运行时异常', + code: `async function main() { throw new Error('boom'); }`, + expect: { success: false, errorMatch: /boom/ } + }, + { + name: '未定义变量', + code: `async function main() { return { val: undefinedVar }; }`, + expect: { success: false } + }, + { + name: '超时', + code: `async function main() { while(true) {} return {}; }`, + expect: { success: false } + }, + { + name: '无限递归', + code: `function recurse() { return recurse(); } +async function main() { return recurse(); }`, + expect: { success: false } + } + ] + ); + }); + + // --- 内置工具函数 --- + describe('内置工具函数', () => { + runMatrix( + () => pool, + [ + { + name: 'delay 正常延迟', + code: `async function main() { + const start = Date.now(); + await delay(500); + const elapsed = Date.now() - start; + return { elapsed: elapsed >= 400 }; + }`, + expect: { success: true, codeReturn: { elapsed: true } } + }, + { + name: 'strToBase64 编码', + code: `async function main() { + const encoded = strToBase64('Hello, World!'); + return { encoded }; + }`, + expect: { success: true, codeReturn: { encoded: 'SGVsbG8sIFdvcmxkIQ==' } } + }, + { + name: 'countToken 计算', + code: `async function main({ text }) { + return { tokens: countToken(text) }; + }`, + variables: { text: 'Hello, this is a test sentence.' }, + expect: { success: true } + } + ] + ); + }); + + // --- 网络请求 --- + describe('网络请求', () => { + runMatrix( + () => pool, + [ + { + name: 'httpRequest GET', + code: `async function main() { + const res = await httpRequest('https://www.baidu.com'); + return { status: res.status, hasData: res.data.length > 0 }; + }`, + expect: { success: true, codeReturnMatch: { status: 200, hasData: true } } + }, + { + name: 'httpRequest POST JSON', + code: `async function main() { + const res = await httpRequest('https://www.baidu.com', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: { message: 'hello' } + }); + return { hasStatus: typeof res.status === 'number' }; + }`, + expect: { success: true, codeReturnMatch: { hasStatus: true } } + } + ] + ); + }); +}); + +// ============================================================ +// Python 功能测试矩阵 +// ============================================================ +describe('Python 功能测试', () => { + let pool: PythonProcessPool; + beforeAll(async () => { + pool = new PythonProcessPool(1); + await pool.init(); + }); + afterAll(async () => { + await pool.shutdown(); + }); + + // --- 基础运算 --- + describe('基础运算', () => { + runMatrix( + () => pool, + [ + { + name: '返回简单字典', + code: `def main():\n return {'hello': 'world'}`, + expect: { success: true, codeReturn: { hello: 'world' } } + }, + { + name: '数学运算', + code: `def main():\n return {'sum': 1 + 2, 'product': 3 * 4, 'power': 2 ** 10}`, + expect: { success: true, codeReturn: { sum: 3, product: 12, power: 1024 } } + }, + { + name: '字符串操作', + code: `def main():\n s = 'Hello, World!'\n return {'upper': s.upper(), 'len': len(s), 'split': s.split(', ')}`, + expect: { + success: true, + codeReturn: { upper: 'HELLO, WORLD!', len: 13, split: ['Hello', 'World!'] } + } + }, + { + name: '列表操作', + code: `def main():\n arr = [3, 1, 4, 1, 5, 9]\n return {'sorted': sorted(arr), 'sum': sum(arr), 'len': len(arr)}`, + expect: { success: true, codeReturn: { sorted: [1, 1, 3, 4, 5, 9], sum: 23, len: 6 } } + }, + { + name: '字典推导式', + code: `def main():\n d = {k: v**2 for k, v in {'a': 1, 'b': 2, 'c': 3}.items()}\n return d`, + expect: { success: true, codeReturn: { a: 1, b: 4, c: 9 } } + }, + { + name: '列表推导式', + code: `def main():\n evens = [x for x in range(10) if x % 2 == 0]\n return {'evens': evens}`, + expect: { success: true, codeReturn: { evens: [0, 2, 4, 6, 8] } } + }, + { + name: 'try/except 异常处理', + code: `def main():\n try:\n result = 1 / 0\n except ZeroDivisionError as e:\n return {'caught': True}\n return {'caught': False}`, + expect: { success: true, codeReturn: { caught: true } } + } + ] + ); + }); + + // --- 变量传递 --- + describe('变量传递', () => { + runMatrix( + () => pool, + [ + { + name: '接收字符串变量', + code: `def main(v):\n return {'greeting': f"Hello, {v['name']}!"}`, + variables: { name: 'Bob' }, + expect: { success: true, codeReturn: { greeting: 'Hello, Bob!' } } + }, + { + name: '接收数字变量', + code: `def main(v):\n return {'doubled': int(v['num']) * 2}`, + variables: { num: '21' }, + expect: { success: true, codeReturn: { doubled: 42 } } + }, + { + name: '多个变量', + code: `def main(v):\n return {'result': f"{v['a']} {v['b']} {v['c']}"}`, + variables: { a: 'hello', b: 'beautiful', c: 'world' }, + expect: { success: true, codeReturn: { result: 'hello beautiful world' } } + }, + { + name: 'main() 无参数', + code: `def main():\n return {'ok': True}`, + variables: { unused: 1 }, + expect: { success: true, codeReturn: { ok: true } } + }, + { + name: 'main(a, b) 多参数展开', + code: `def main(name, age):\n return {'name': name, 'age': age}`, + variables: { name: 'test', age: 25 }, + expect: { success: true, codeReturn: { name: 'test', age: 25 } } + } + ] + ); + }); + + // --- 安全模块使用 --- + describe('安全模块', () => { + runMatrix( + () => pool, + [ + { + name: 'import json', + code: `import json\ndef main():\n data = json.dumps({'key': 'value'}, ensure_ascii=False)\n parsed = json.loads(data)\n return {'data': data, 'key': parsed['key']}`, + expect: { success: true, codeReturnMatch: { key: 'value' } } + }, + { + name: 'import math', + code: `import math\ndef main():\n return {'pi': round(math.pi, 4), 'sqrt2': round(math.sqrt(2), 4), 'e': round(math.e, 4)}`, + expect: { success: true, codeReturn: { pi: 3.1416, sqrt2: 1.4142, e: 2.7183 } } + }, + { + name: 'import re', + code: `import re\ndef main():\n text = 'Price: $12.99, Tax: $1.30'\n prices = re.findall(r'\\$(\\d+\\.\\d+)', text)\n return {'prices': prices}`, + expect: { success: true, codeReturn: { prices: ['12.99', '1.30'] } } + }, + { + name: 'from datetime import datetime', + code: `from datetime import datetime\ndef main():\n d = datetime(2024, 1, 15, 12, 0, 0)\n return {'iso': d.isoformat(), 'year': d.year}`, + expect: { success: true, codeReturn: { iso: '2024-01-15T12:00:00', year: 2024 } } + }, + { + name: 'import hashlib', + code: `import hashlib\ndef main():\n h = hashlib.md5(b'hello').hexdigest()\n return {'md5': h}`, + expect: { success: true, codeReturn: { md5: '5d41402abc4b2a76b9719d911017c592' } } + }, + { + name: 'import base64', + code: `import base64\ndef main():\n encoded = base64.b64encode(b'Hello').decode()\n decoded = base64.b64decode(encoded).decode()\n return {'encoded': encoded, 'decoded': decoded}`, + expect: { success: true, codeReturn: { encoded: 'SGVsbG8=', decoded: 'Hello' } } + }, + { + name: 'import hashlib sha256', + code: `import hashlib\ndef main():\n h = hashlib.sha256(b'hello').hexdigest()\n return {'hash': h}`, + expect: { + success: true, + codeReturn: { hash: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824' } + } + }, + { + name: 'from collections import Counter', + code: `from collections import Counter\ndef main(v):\n c = Counter(v['items'])\n return dict(c.most_common())`, + variables: { items: ['a', 'b', 'a', 'c', 'a', 'b'] }, + expect: { success: true, codeReturnMatch: { a: 3, b: 2 } } + }, + { + name: 'datetime + timedelta', + code: `from datetime import datetime, timedelta\ndef main():\n d = datetime(2024, 1, 15)\n next_week = d + timedelta(days=7)\n return {'formatted': d.strftime('%Y/%m/%d'), 'next_week': next_week.strftime('%Y/%m/%d')}`, + expect: { + success: true, + codeReturn: { formatted: '2024/01/15', next_week: '2024/01/22' } + } + } + ] + ); + }); + + // --- 内置工具函数 --- + describe('内置工具函数', () => { + runMatrix( + () => pool, + [ + { + name: 'str_to_base64 编码', + code: `def main():\n encoded = str_to_base64('Hello, World!')\n return {'encoded': encoded}`, + expect: { success: true, codeReturn: { encoded: 'SGVsbG8sIFdvcmxkIQ==' } } + }, + { + name: 'delay 正常延迟', + code: `import time\ndef main():\n start = time.time()\n delay(500)\n elapsed = time.time() - start\n return {'elapsed_ok': elapsed >= 0.4}`, + expect: { success: true, codeReturn: { elapsed_ok: true } } + }, + { + name: 'count_token 计算', + code: `def main(v):\n return {'tokens': count_token(v['text'])}`, + variables: { text: 'Hello, this is a test sentence.' }, + expect: { success: true } + } + ] + ); + }); + + // --- 错误处理 --- + describe('错误处理', () => { + runMatrix( + () => pool, + [ + { + name: '语法错误', + code: `def main():\n return {{{`, + expect: { success: false } + }, + { + name: '运行时异常', + code: `def main():\n raise ValueError('boom')`, + expect: { success: false, errorMatch: /boom/ } + }, + { + name: '未定义变量', + code: `def main():\n return {'val': undefined_var}`, + expect: { success: false } + }, + { + name: '超时', + code: `def main():\n while True:\n pass\n return {}`, + expect: { success: false } + }, + { + name: '除零错误', + code: `def main():\n return {'value': 1 / 0}`, + expect: { success: false } + }, + { + name: '索引越界', + code: `def main():\n arr = [1, 2, 3]\n return {'value': arr[10]}`, + expect: { success: false } + }, + { + name: '无限递归', + code: `def recurse():\n return recurse()\ndef main():\n return recurse()`, + expect: { success: false } + } + ] + ); + }); + + // --- 网络请求 --- + describe('网络请求', () => { + runMatrix( + () => pool, + [ + { + name: 'http_request GET', + code: `import json\ndef main():\n res = http_request('https://www.baidu.com')\n return {'status': res['status'], 'hasData': len(res['data']) > 0}`, + expect: { success: true, codeReturnMatch: { status: 200, hasData: true } } + }, + { + name: 'http_request POST JSON', + code: `import json\ndef main():\n res = http_request('https://www.baidu.com', method='POST', body={'message': 'hello'})\n return {'hasStatus': type(res['status']) == int}`, + expect: { success: true, codeReturnMatch: { hasStatus: true } } + } + ] + ); + }); + + // --- 复杂场景 --- + describe('复杂场景', () => { + runMatrix( + () => pool, + [ + { + name: '数据处理管道:解析CSV → 过滤 → 聚合', + code: `def main(v): + lines = v['csv'].strip().split('\\n') + header = lines[0].split(',') + rows = [dict(zip(header, line.split(','))) for line in lines[1:]] + adults = [r for r in rows if int(r['age']) >= 18] + avg_age = sum(int(r['age']) for r in adults) / len(adults) + return {'total': len(rows), 'adults': len(adults), 'avg_age': avg_age}`, + variables: { csv: 'name,age\nAlice,25\nBob,17\nCharlie,30\nDiana,15' }, + expect: { success: true, codeReturn: { total: 4, adults: 2, avg_age: 27.5 } } + }, + { + name: '递归:斐波那契', + code: `def main(): + def fib(n): + if n <= 1: return n + return fib(n-1) + fib(n-2) + return {'fib10': fib(10), 'fib20': fib(20)}`, + expect: { success: true, codeReturn: { fib10: 55, fib20: 6765 } } + }, + { + name: '类定义和使用', + code: `def main(): + class Point: + def __init__(self, x, y): + self.x = x + self.y = y + def distance(self, other): + return ((self.x - other.x)**2 + (self.y - other.y)**2)**0.5 + p1 = Point(0, 0) + p2 = Point(3, 4) + return {'distance': p1.distance(p2)}`, + expect: { success: true, codeReturn: { distance: 5.0 } } + } + ] + ); + }); +}); diff --git a/projects/sandbox/test/tsconfig.json b/projects/sandbox/test/tsconfig.json deleted file mode 100644 index 420c417a6b..0000000000 --- a/projects/sandbox/test/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "es2022", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "node", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "baseUrl": "." - }, - "include": ["**/*.test.ts"], - "exclude": ["**/node_modules"] -} diff --git a/projects/sandbox/test/unit/boundary.test.ts b/projects/sandbox/test/unit/boundary.test.ts new file mode 100644 index 0000000000..20678f77d2 --- /dev/null +++ b/projects/sandbox/test/unit/boundary.test.ts @@ -0,0 +1,373 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ProcessPool } from '../../src/pool/process-pool'; +import { PythonProcessPool } from '../../src/pool/python-process-pool'; + +let jsPool: ProcessPool; +let pyPool: PythonProcessPool; + +beforeAll(async () => { + jsPool = new ProcessPool(1); + await jsPool.init(); + pyPool = new PythonProcessPool(1); + await pyPool.init(); +}); + +afterAll(async () => { + await jsPool.shutdown(); + await pyPool.shutdown(); +}); + +describe('边界测试 - JS', () => { + // ===== 空/特殊代码 ===== + + it('空代码(无 main 函数)', async () => { + const result = await jsPool.execute({ code: '', variables: {} }); + expect(result.success).toBe(false); + }); + + it('main 不是函数', async () => { + const result = await jsPool.execute({ + code: `const main = 42;`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('main 返回 undefined', async () => { + const result = await jsPool.execute({ + code: `async function main() { }`, + variables: {} + }); + expect(result.success).toBe(true); + }); + + it('main 返回 null', async () => { + const result = await jsPool.execute({ + code: `async function main() { return null; }`, + variables: {} + }); + expect(result.success).toBe(true); + }); + + // ===== 大数据 ===== + + it('大量 console.log 输出', async () => { + const result = await jsPool.execute({ + code: `async function main() { + for (let i = 0; i < 1000; i++) { + console.log('line ' + i); + } + return { done: true }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.log).toContain('line 0'); + expect(result.data?.log).toContain('line 999'); + }); + + it('大对象返回', async () => { + const result = await jsPool.execute({ + code: `async function main() { + const arr = []; + for (let i = 0; i < 10000; i++) arr.push(i); + return { count: arr.length, first: arr[0], last: arr[9999] }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.count).toBe(10000); + }); + + // ===== 变量传递 ===== + + it('特殊字符变量', async () => { + const result = await jsPool.execute({ + code: `async function main(vars) { + return { name: vars.name }; + }`, + variables: { name: '你好\n"world"' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.name).toBe('你好\n"world"'); + }); + + it('嵌套对象变量', async () => { + const result = await jsPool.execute({ + code: `async function main(vars) { + return { deep: vars.a.b.c }; + }`, + variables: { a: { b: { c: 42 } } } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.deep).toBe(42); + }); + + it('数组变量', async () => { + const result = await jsPool.execute({ + code: `async function main(vars) { + return { len: vars.items.length, first: vars.items[0] }; + }`, + variables: { items: [1, 2, 3] } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.len).toBe(3); + }); +}); + +describe('边界测试 - Python', () => { + // ===== 空/特殊代码 ===== + + it('空代码', async () => { + const result = await pyPool.execute({ code: '', variables: {} }); + expect(result.success).toBe(false); + }); + + it('main 不是函数', async () => { + const result = await pyPool.execute({ + code: `main = 42`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('main 返回 None', async () => { + const result = await pyPool.execute({ + code: `def main(): + pass`, + variables: {} + }); + expect(result.success).toBe(true); + }); + + // ===== 大数据 ===== + + it('大量 print 输出', async () => { + const result = await pyPool.execute({ + code: `def main(): + for i in range(1000): + print(f'line {i}') + return {'done': True}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.log).toContain('line 0'); + expect(result.data?.log).toContain('line 999'); + }); + + it('大列表返回', async () => { + const result = await pyPool.execute({ + code: `def main(): + arr = list(range(10000)) + return {'count': len(arr), 'first': arr[0], 'last': arr[-1]}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.count).toBe(10000); + }); + + // ===== 变量传递 ===== + + it('特殊字符变量', async () => { + const result = await pyPool.execute({ + code: `def main(vars): + return {'name': vars['name']}`, + variables: { name: '你好\n"world"' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.name).toBe('你好\n"world"'); + }); + + it('嵌套字典变量', async () => { + const result = await pyPool.execute({ + code: `def main(vars): + return {'deep': vars['a']['b']['c']}`, + variables: { a: { b: { c: 42 } } } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.deep).toBe(42); + }); + + it('列表变量', async () => { + const result = await pyPool.execute({ + code: `def main(vars): + return {'len': len(vars['items']), 'first': vars['items'][0]}`, + variables: { items: [1, 2, 3] } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.len).toBe(3); + }); + + // ===== 类型处理 ===== + + it('返回非 JSON 可序列化对象(set)', async () => { + const result = await pyPool.execute({ + code: `def main(): + return {'items': list({1, 2, 3})}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.items).toHaveLength(3); + }); + + it('返回 datetime 对象(default=str 处理)', async () => { + const result = await pyPool.execute({ + code: `from datetime import datetime +def main(): + return {'now': datetime(2024, 1, 1, 12, 0, 0)}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.now).toContain('2024'); + }); + + // ===== 补充:更多边界场景 ===== + + it('超长变量字符串', async () => { + const longStr = 'a'.repeat(100000); + const result = await pyPool.execute({ + code: `def main(v): + return {'len': len(v['text'])}`, + variables: { text: longStr } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.len).toBe(100000); + }); + + it('变量包含特殊 JSON 字符', async () => { + const result = await pyPool.execute({ + code: `def main(v): + return {'text': v['text']}`, + variables: { text: 'line1\nline2\ttab\\backslash"quote' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.text).toContain('line1'); + expect(result.data?.codeReturn.text).toContain('\\'); + }); + + it('返回浮点数精度', async () => { + const result = await pyPool.execute({ + code: `def main(): + return {'val': 0.1 + 0.2}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.val).toBeCloseTo(0.3, 10); + }); + + it('返回非常大的整数', async () => { + const result = await pyPool.execute({ + code: `def main(): + return {'big': 2 ** 53}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.big).toBe(9007199254740992); + }); + + it('缺少必需参数的 main 函数', async () => { + const result = await pyPool.execute({ + code: `def main(a, b, c): + return {'sum': a + b + c}`, + variables: { a: 1, b: 2 } // 缺少 c + }); + expect(result.success).toBe(false); + expect(result.message).toContain('Missing'); + }); +}); + +describe('边界测试 - JS 补充', () => { + it('超长变量字符串', async () => { + const longStr = 'a'.repeat(100000); + const result = await jsPool.execute({ + code: `async function main(v) { + return { len: v.text.length }; + }`, + variables: { text: longStr } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.len).toBe(100000); + }); + + it('变量包含特殊 JSON 字符', async () => { + const result = await jsPool.execute({ + code: `async function main(v) { + return { text: v.text }; + }`, + variables: { text: 'line1\nline2\ttab\\backslash"quote' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.text).toContain('line1'); + }); + + it('返回浮点数精度', async () => { + const result = await jsPool.execute({ + code: `async function main() { + return { val: 0.1 + 0.2 }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.val).toBeCloseTo(0.3, 10); + }); + + it('Promise.reject 被正确捕获', async () => { + const result = await jsPool.execute({ + code: `async function main() { + await Promise.reject(new Error('rejected')); + }`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toContain('rejected'); + }); + + it('setTimeout 在沙盒中可用', async () => { + const result = await jsPool.execute({ + code: `async function main() { + return new Promise(resolve => { + setTimeout(() => resolve({ ok: true }), 50); + }); + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.ok).toBe(true); + }); + + it('JSON 循环引用返回错误', async () => { + const result = await jsPool.execute({ + code: `async function main() { + const obj = {}; + obj.self = obj; + return obj; + }`, + variables: {} + }); + // JSON.stringify 循环引用会抛错 + expect(result.success).toBe(false); + }); + + it('缺少 main 函数', async () => { + const result = await jsPool.execute({ + code: `const x = 42;`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('async 函数中 try/catch 正常工作', async () => { + const result = await jsPool.execute({ + code: `async function main() { + try { + JSON.parse('invalid json'); + } catch(e) { + return { caught: true, msg: e.message }; + } + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.caught).toBe(true); + }); +}); diff --git a/projects/sandbox/test/unit/process-pool.test.ts b/projects/sandbox/test/unit/process-pool.test.ts new file mode 100644 index 0000000000..b5b392fdca --- /dev/null +++ b/projects/sandbox/test/unit/process-pool.test.ts @@ -0,0 +1,961 @@ +/** + * ProcessPool / PythonProcessPool 单元测试 + * + * 覆盖进程池核心逻辑: + * - 生命周期(init / shutdown / stats) + * - Worker 崩溃自动恢复(respawn) + * - 池满排队行为 + * - 并发正确性 + * - shutdown 后行为 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ProcessPool } from '../../src/pool/process-pool'; +import { PythonProcessPool } from '../../src/pool/python-process-pool'; + +// ============================================================ +// JS ProcessPool +// ============================================================ +describe('ProcessPool 生命周期', () => { + let pool: ProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('init 后 stats 正确', async () => { + pool = new ProcessPool(2); + await pool.init(); + const s = pool.stats; + expect(s.total).toBe(2); + expect(s.idle).toBe(2); + expect(s.busy).toBe(0); + expect(s.queued).toBe(0); + expect(s.poolSize).toBe(2); + }); + + it('shutdown 后 stats 归零', async () => { + pool = new ProcessPool(2); + await pool.init(); + await pool.shutdown(); + const s = pool.stats; + expect(s.total).toBe(0); + expect(s.idle).toBe(0); + expect(s.busy).toBe(0); + }); + + it('execute 后 worker 归还到 idle', async () => { + pool = new ProcessPool(1); + await pool.init(); + await pool.execute({ + code: `async function main() { return { ok: true }; }`, + variables: {} + }); + const s = pool.stats; + expect(s.idle).toBe(1); + expect(s.busy).toBe(0); + }); +}); + +describe('ProcessPool Worker 恢复', () => { + let pool: ProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('worker 崩溃后自动 respawn,后续请求正常', async () => { + pool = new ProcessPool(1); + await pool.init(); + expect(pool.stats.total).toBe(1); + + // 让 worker 崩溃(process.exit) + const result = await pool.execute({ + code: `async function main() { process.exit(1); }`, + variables: {} + }); + expect(result.success).toBe(false); + + // 等 respawn 完成 + await new Promise((r) => setTimeout(r, 1500)); + + // 新 worker 应该可用 + const result2 = await pool.execute({ + code: `async function main() { return { recovered: true }; }`, + variables: {} + }); + expect(result2.success).toBe(true); + expect(result2.data?.codeReturn.recovered).toBe(true); + }); + + it('超时后 worker 被 kill 并 respawn', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `async function main() { while(true) {} }`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toContain('timed out'); + + // 等 respawn + await new Promise((r) => setTimeout(r, 1500)); + + const result2 = await pool.execute({ + code: `async function main() { return { ok: true }; }`, + variables: {} + }); + expect(result2.success).toBe(true); + }); +}); + +describe('ProcessPool 并发与排队', () => { + let pool: ProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('pool size=2,3 个并发请求,1 个排队', async () => { + pool = new ProcessPool(2); + await pool.init(); + + // 3 个并发,每个 sleep 200ms + const promises = Array.from({ length: 3 }, (_, i) => + pool.execute({ + code: `async function main(v) { await new Promise(r => setTimeout(r, 200)); return { idx: v.idx }; }`, + variables: { idx: i } + }) + ); + + const results = await Promise.all(promises); + for (let i = 0; i < 3; i++) { + expect(results[i].success).toBe(true); + expect(results[i].data?.codeReturn.idx).toBe(i); + } + }); + + it('pool size=1,10 个并发请求全部正确完成(串行排队)', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const promises = Array.from({ length: 10 }, (_, i) => + pool.execute({ + code: `async function main(v) { return { n: v.n * 2 }; }`, + variables: { n: i } + }) + ); + + const results = await Promise.all(promises); + for (let i = 0; i < 10; i++) { + expect(results[i].success).toBe(true); + expect(results[i].data?.codeReturn.n).toBe(i * 2); + } + }); + + it('pool size=2,并发中 1 个崩溃不影响其他请求', async () => { + pool = new ProcessPool(2); + await pool.init(); + + const p1 = pool.execute({ + code: `async function main() { process.exit(1); }`, + variables: {} + }); + const p2 = pool.execute({ + code: `async function main() { return { ok: true }; }`, + variables: {} + }); + + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1.success).toBe(false); + expect(r2.success).toBe(true); + expect(r2.data?.codeReturn.ok).toBe(true); + }); +}); + +// ============================================================ +// JS ProcessPool - Worker Ping/Pong 健康检查 +// ============================================================ +describe('ProcessPool Worker 健康检查 (ping/pong)', () => { + let pool: ProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('worker 正常响应 ping 后仍可执行任务', async () => { + pool = new ProcessPool(1); + await pool.init(); + + // 先执行一个任务确认正常 + const r1 = await pool.execute({ + code: `async function main() { return { step: 1 }; }`, + variables: {} + }); + expect(r1.success).toBe(true); + expect(r1.data?.codeReturn.step).toBe(1); + + // 触发健康检查(通过 triggerHealthCheck) + (pool as any).pingWorker((pool as any).idleWorkers[0]); + + // 等 ping/pong 完成 + await new Promise((r) => setTimeout(r, 500)); + + // 再执行一个任务确认 worker 没被误杀 + const r2 = await pool.execute({ + code: `async function main() { return { step: 2 }; }`, + variables: {} + }); + expect(r2.success).toBe(true); + expect(r2.data?.codeReturn.step).toBe(2); + expect(pool.stats.total).toBe(1); + }); + + it('连续多次 ping 不影响 worker 状态', async () => { + pool = new ProcessPool(2); + await pool.init(); + + // 对所有 idle worker 连续 ping 3 次 + for (let i = 0; i < 3; i++) { + for (const w of [...(pool as any).idleWorkers]) { + (pool as any).pingWorker(w); + } + await new Promise((r) => setTimeout(r, 300)); + } + + // 所有 worker 应该还在 + expect(pool.stats.total).toBe(2); + expect(pool.stats.idle).toBe(2); + + // 执行任务确认功能正常 + const result = await pool.execute({ + code: `async function main() { return { alive: true }; }`, + variables: {} + }); + expect(result.success).toBe(true); + }); +}); + +// ============================================================ +// JS ProcessPool - shutdown reject waiters +// ============================================================ +describe('ProcessPool shutdown reject waiters', () => { + it('shutdown 后 waitQueue 中的请求被 reject', async () => { + const pool = new ProcessPool(1); + await pool.init(); + + // 发起一个长时间运行的任务占住唯一 worker + const p1 = pool.execute({ + code: `async function main() { await new Promise(r => setTimeout(r, 3000)); return { done: true }; }`, + variables: {} + }); + + // 等一下确保 p1 已经拿到 worker + await new Promise((r) => setTimeout(r, 200)); + + // 发起第二个请求,它会进入 waitQueue + const p2 = pool.execute({ + code: `async function main() { return { queued: true }; }`, + variables: {} + }); + + // 确认有排队请求 + expect(pool.stats.queued).toBe(1); + + // shutdown 应该 reject waitQueue 中的请求 + await pool.shutdown(); + + // p2 应该被 reject + await expect(p2).rejects.toThrow('shutting down'); + + // p1 可能成功也可能因 worker 被 kill 而失败,不关心 + await p1.catch(() => {}); + }); +}); + +// ============================================================ +// JS ProcessPool - 返回值序列化与参数校验(原 base-runner.test.ts) +// ============================================================ +describe('ProcessPool 返回值序列化与参数校验', () => { + let pool: ProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('JS main 返回 undefined 序列化为 null', async () => { + pool = new ProcessPool(1); + await pool.init(); + const result = await pool.execute({ + code: `async function main() { return undefined; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn).toBeNull(); + }); + + it('JS main 无 return 语句序列化为 null', async () => { + pool = new ProcessPool(1); + await pool.init(); + const result = await pool.execute({ + code: `async function main() { const x = 1; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn).toBeNull(); + }); + + it('code 为非字符串类型返回错误', async () => { + pool = new ProcessPool(1); + await pool.init(); + const result = await pool.execute({ + code: 123 as any, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toContain('empty'); + }); + + it('code 为 null 返回错误', async () => { + pool = new ProcessPool(1); + await pool.init(); + const result = await pool.execute({ + code: null as any, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toContain('empty'); + }); +}); + +// ============================================================ +// JS + Python 混合并发(原 base-runner.test.ts) +// ============================================================ +describe('JS + Python 混合并发', () => { + let jsPool: ProcessPool; + let pyPool: PythonProcessPool; + + afterEach(async () => { + try { + await jsPool?.shutdown(); + await pyPool?.shutdown(); + } catch {} + }); + + it('JS 和 Python 混合并发执行', async () => { + jsPool = new ProcessPool(1); + await jsPool.init(); + pyPool = new PythonProcessPool(1); + await pyPool.init(); + + const jsPromise = jsPool.execute({ + code: `async function main() { return { lang: 'js' }; }`, + variables: {} + }); + const pyPromise = pyPool.execute({ + code: `def main():\n return {'lang': 'python'}`, + variables: {} + }); + const [jsResult, pyResult] = await Promise.all([jsPromise, pyPromise]); + expect(jsResult.success).toBe(true); + expect(jsResult.data?.codeReturn.lang).toBe('js'); + expect(pyResult.success).toBe(true); + expect(pyResult.data?.codeReturn.lang).toBe('python'); + }); +}); + +// ============================================================ +// JS ProcessPool - 健康检查失败路径 +// ============================================================ +describe('ProcessPool 健康检查失败路径', () => { + let pool: ProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('ping timeout: worker 不响应 pong 时被替换', async () => { + pool = new ProcessPool(1); + await pool.init(); + expect(pool.stats.total).toBe(1); + + const worker = (pool as any).idleWorkers[0]; + // 拦截 stdin.write 使 ping 消息不到达 worker(但不关闭 stdin),从而触发真正的 timeout + const origWrite = worker.proc.stdin!.write.bind(worker.proc.stdin!); + let interceptPing = true; + worker.proc.stdin!.write = (...args: any[]) => { + if (interceptPing) { + interceptPing = false; + return true; // 假装写成功但实际不发送 + } + return origWrite(...args); + }; + + // 触发 ping + (pool as any).pingWorker(worker); + + // 等待 HEALTH_CHECK_TIMEOUT (5s) + respawn + await new Promise((r) => setTimeout(r, 8000)); + + // worker 应该被替换,池仍然有 1 个 worker + expect(pool.stats.total).toBe(1); + + // 新 worker 应该可用 + const result = await pool.execute({ + code: `async function main() { return { ok: true }; }`, + variables: {} + }); + expect(result.success).toBe(true); + }, 15000); + + it('stdin not writable: worker stdin 关闭时被替换', async () => { + pool = new ProcessPool(1); + await pool.init(); + expect(pool.stats.total).toBe(1); + + const worker = (pool as any).idleWorkers[0]; + // 销毁 stdin 使其 writable = false + worker.proc.stdin!.destroy(); + + // 触发 ping + (pool as any).pingWorker(worker); + + // 等 respawn + await new Promise((r) => setTimeout(r, 3000)); + + expect(pool.stats.total).toBe(1); + + const result = await pool.execute({ + code: `async function main() { return { replaced: true }; }`, + variables: {} + }); + expect(result.success).toBe(true); + }, 10000); + + it('health check invalid response: worker 返回错误类型时被替换', async () => { + pool = new ProcessPool(1); + await pool.init(); + expect(pool.stats.total).toBe(1); + + const worker = (pool as any).idleWorkers[0]; + const origWrite = worker.proc.stdin!.write.bind(worker.proc.stdin!); + let intercepted = false; + worker.proc.stdin!.write = (...args: any[]) => { + if (!intercepted) { + intercepted = true; + setTimeout(() => worker.rl.emit('line', JSON.stringify({ type: 'wrong' })), 50); + return true; + } + return origWrite(...args); + }; + + (pool as any).pingWorker(worker); + + await new Promise((r) => setTimeout(r, 3000)); + + expect(pool.stats.total).toBe(1); + + const result = await pool.execute({ + code: `async function main() { return { invalidResp: true }; }`, + variables: {} + }); + expect(result.success).toBe(true); + }, 10000); + + it('returnToIdle with waiter: ping 期间有等待请求时直接分配', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const worker = (pool as any).idleWorkers[0]; + (pool as any).pingWorker(worker); + + // ping 期间 worker 不在 idle 中,新请求进入 waitQueue + // ping 成功后 returnToIdle 检查 waitQueue 并直接分配 + const p1 = pool.execute({ + code: `async function main() { return { fromWaiter: true }; }`, + variables: {} + }); + + const result = await p1; + expect(result.success).toBe(true); + expect(result.data?.codeReturn.fromWaiter).toBe(true); + }); + + it('health check parse error: worker 返回非 JSON 时被替换', async () => { + pool = new ProcessPool(1); + await pool.init(); + expect(pool.stats.total).toBe(1); + + const worker = (pool as any).idleWorkers[0]; + const origWrite = worker.proc.stdin!.write.bind(worker.proc.stdin!); + let intercepted = false; + worker.proc.stdin!.write = (...args: any[]) => { + if (!intercepted) { + intercepted = true; + setTimeout(() => worker.rl.emit('line', 'not-json-at-all'), 50); + return true; + } + return origWrite(...args); + }; + + (pool as any).pingWorker(worker); + + await new Promise((r) => setTimeout(r, 3000)); + + expect(pool.stats.total).toBe(1); + + const result = await pool.execute({ + code: `async function main() { return { parseError: true }; }`, + variables: {} + }); + expect(result.success).toBe(true); + }, 10000); + + it('health check write error: stdin.write 抛异常时被替换', async () => { + pool = new ProcessPool(1); + await pool.init(); + expect(pool.stats.total).toBe(1); + + const worker = (pool as any).idleWorkers[0]; + // 让 stdin.write 抛异常,但 writable 仍为 true + worker.proc.stdin!.write = () => { + throw new Error('mock write error'); + }; + + (pool as any).pingWorker(worker); + + await new Promise((r) => setTimeout(r, 3000)); + + expect(pool.stats.total).toBe(1); + + const result = await pool.execute({ + code: `async function main() { return { writeError: true }; }`, + variables: {} + }); + expect(result.success).toBe(true); + }, 10000); + + it('returnToIdle with waiter: ping 成功后分配给等待中的请求', async () => { + pool = new ProcessPool(1); + await pool.init(); + + // 发起一个长任务占住 worker + const p1 = pool.execute({ + code: `async function main() { await new Promise(r => setTimeout(r, 1000)); return { first: true }; }`, + variables: {} + }); + + // 等 p1 拿到 worker + await new Promise((r) => setTimeout(r, 100)); + + // 发起第二个请求,它会进入 waitQueue + const p2 = pool.execute({ + code: `async function main() { return { second: true }; }`, + variables: {} + }); + + // 确认有排队 + expect(pool.stats.queued).toBe(1); + + // 等 p1 完成,p2 应该自动被分配 + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1.success).toBe(true); + expect(r1.data?.codeReturn.first).toBe(true); + expect(r2.success).toBe(true); + expect(r2.data?.codeReturn.second).toBe(true); + }); +}); + +// ============================================================ +// Python PythonProcessPool - Worker Ping/Pong 健康检查 +// ============================================================ +describe('PythonProcessPool Worker 健康检查 (ping/pong)', () => { + let pool: PythonProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('worker 正常响应 ping 后仍可执行任务', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const r1 = await pool.execute({ + code: `def main():\n return {'step': 1}`, + variables: {} + }); + expect(r1.success).toBe(true); + expect(r1.data?.codeReturn.step).toBe(1); + + // 触发 ping + (pool as any).pingWorker((pool as any).idleWorkers[0]); + await new Promise((r) => setTimeout(r, 500)); + + const r2 = await pool.execute({ + code: `def main():\n return {'step': 2}`, + variables: {} + }); + expect(r2.success).toBe(true); + expect(r2.data?.codeReturn.step).toBe(2); + expect(pool.stats.total).toBe(1); + }); + + it('连续多次 ping 不影响 worker 状态', async () => { + pool = new PythonProcessPool(2); + await pool.init(); + + for (let i = 0; i < 3; i++) { + for (const w of [...(pool as any).idleWorkers]) { + (pool as any).pingWorker(w); + } + await new Promise((r) => setTimeout(r, 300)); + } + + expect(pool.stats.total).toBe(2); + expect(pool.stats.idle).toBe(2); + + const result = await pool.execute({ + code: `def main():\n return {'alive': True}`, + variables: {} + }); + expect(result.success).toBe(true); + }); +}); + +// ============================================================ +// Python PythonProcessPool - 健康检查失败路径 +// ============================================================ +describe('PythonProcessPool 健康检查失败路径', () => { + let pool: PythonProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('ping timeout: worker 不响应 pong 时被替换', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + expect(pool.stats.total).toBe(1); + + const worker = (pool as any).idleWorkers[0]; + // 拦截 stdin.write 使 ping 不到达 worker,触发真正的 timeout + const origWrite = worker.proc.stdin!.write.bind(worker.proc.stdin!); + let interceptPing = true; + worker.proc.stdin!.write = (...args: any[]) => { + if (interceptPing) { + interceptPing = false; + return true; + } + return origWrite(...args); + }; + + (pool as any).pingWorker(worker); + + await new Promise((r) => setTimeout(r, 8000)); + + expect(pool.stats.total).toBe(1); + + const result = await pool.execute({ + code: `def main():\n return {'ok': True}`, + variables: {} + }); + expect(result.success).toBe(true); + }, 15000); + + it('stdin not writable: worker stdin 关闭时被替换', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + expect(pool.stats.total).toBe(1); + + const worker = (pool as any).idleWorkers[0]; + worker.proc.stdin!.destroy(); + + (pool as any).pingWorker(worker); + + await new Promise((r) => setTimeout(r, 3000)); + + expect(pool.stats.total).toBe(1); + + const result = await pool.execute({ + code: `def main():\n return {'replaced': True}`, + variables: {} + }); + expect(result.success).toBe(true); + }, 10000); + + it('health check invalid response: worker 返回错误类型时被替换', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + expect(pool.stats.total).toBe(1); + + const worker = (pool as any).idleWorkers[0]; + const origWrite = worker.proc.stdin!.write.bind(worker.proc.stdin!); + let intercepted = false; + worker.proc.stdin!.write = (...args: any[]) => { + if (!intercepted) { + intercepted = true; + setTimeout(() => worker.rl.emit('line', JSON.stringify({ type: 'wrong' })), 50); + return true; + } + return origWrite(...args); + }; + + (pool as any).pingWorker(worker); + + await new Promise((r) => setTimeout(r, 3000)); + + expect(pool.stats.total).toBe(1); + + const result = await pool.execute({ + code: `def main():\n return {'invalidResp': True}`, + variables: {} + }); + expect(result.success).toBe(true); + }, 10000); + + it('returnToIdle with waiter: ping 期间有等待请求时直接分配', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const worker = (pool as any).idleWorkers[0]; + (pool as any).pingWorker(worker); + + const p1 = pool.execute({ + code: `def main():\n return {'fromWaiter': True}`, + variables: {} + }); + + const result = await p1; + expect(result.success).toBe(true); + expect(result.data?.codeReturn.fromWaiter).toBe(true); + }); + + it('health check parse error: worker 返回非 JSON 时被替换', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + expect(pool.stats.total).toBe(1); + + const worker = (pool as any).idleWorkers[0]; + const origWrite = worker.proc.stdin!.write.bind(worker.proc.stdin!); + let intercepted = false; + worker.proc.stdin!.write = (...args: any[]) => { + if (!intercepted) { + intercepted = true; + setTimeout(() => worker.rl.emit('line', 'not-json'), 50); + return true; + } + return origWrite(...args); + }; + + (pool as any).pingWorker(worker); + + await new Promise((r) => setTimeout(r, 3000)); + + expect(pool.stats.total).toBe(1); + + const result = await pool.execute({ + code: `def main():\n return {'parseError': True}`, + variables: {} + }); + expect(result.success).toBe(true); + }, 10000); + + it('health check write error: stdin.write 抛异常时被替换', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + expect(pool.stats.total).toBe(1); + + const worker = (pool as any).idleWorkers[0]; + worker.proc.stdin!.write = () => { + throw new Error('mock write error'); + }; + + (pool as any).pingWorker(worker); + + await new Promise((r) => setTimeout(r, 3000)); + + expect(pool.stats.total).toBe(1); + + const result = await pool.execute({ + code: `def main():\n return {'writeError': True}`, + variables: {} + }); + expect(result.success).toBe(true); + }, 10000); +}); + +// ============================================================ +// Python PythonProcessPool - shutdown reject waiters +// ============================================================ +describe('PythonProcessPool shutdown reject waiters', () => { + it('shutdown 后 waitQueue 中的请求被 reject', async () => { + const pool = new PythonProcessPool(1); + await pool.init(); + + // 发起一个长时间运行的任务占住唯一 worker + const p1 = pool.execute({ + code: `import time\ndef main():\n time.sleep(3)\n return {'done': True}`, + variables: {} + }); + + // 等一下确保 p1 已经拿到 worker + await new Promise((r) => setTimeout(r, 200)); + + // 发起第二个请求,它会进入 waitQueue + const p2 = pool.execute({ + code: `def main():\n return {'queued': True}`, + variables: {} + }); + + // 确认有排队请求 + expect(pool.stats.queued).toBe(1); + + // shutdown 应该 reject waitQueue 中的请求 + await pool.shutdown(); + + // p2 应该被 reject + await expect(p2).rejects.toThrow('shutting down'); + + // p1 可能成功也可能因 worker 被 kill 而失败,不关心 + await p1.catch(() => {}); + }); +}); + +// ============================================================ +// Python PythonProcessPool +// ============================================================ +describe('PythonProcessPool 生命周期', () => { + let pool: PythonProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('init 后 stats 正确', async () => { + pool = new PythonProcessPool(2); + await pool.init(); + const s = pool.stats; + expect(s.total).toBe(2); + expect(s.idle).toBe(2); + expect(s.busy).toBe(0); + expect(s.queued).toBe(0); + expect(s.poolSize).toBe(2); + }); + + it('shutdown 后 stats 归零', async () => { + pool = new PythonProcessPool(2); + await pool.init(); + await pool.shutdown(); + const s = pool.stats; + expect(s.total).toBe(0); + expect(s.idle).toBe(0); + expect(s.busy).toBe(0); + }); + + it('execute 后 worker 归还到 idle', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + await pool.execute({ + code: `def main():\n return {'ok': True}`, + variables: {} + }); + const s = pool.stats; + expect(s.idle).toBe(1); + expect(s.busy).toBe(0); + }); +}); + +describe('PythonProcessPool Worker 恢复', () => { + let pool: PythonProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('超时后 worker 被 kill 并 respawn', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `def main():\n while True:\n pass`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toContain('timed out'); + + // 等 respawn + await new Promise((r) => setTimeout(r, 2000)); + + const result2 = await pool.execute({ + code: `def main():\n return {'ok': True}`, + variables: {} + }); + expect(result2.success).toBe(true); + }); +}); + +describe('PythonProcessPool 并发与排队', () => { + let pool: PythonProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('pool size=2,3 个并发请求,1 个排队', async () => { + pool = new PythonProcessPool(2); + await pool.init(); + + const promises = Array.from({ length: 3 }, (_, i) => + pool.execute({ + code: `import time\ndef main(variables):\n time.sleep(0.2)\n return {'idx': variables['idx']}`, + variables: { idx: i } + }) + ); + + const results = await Promise.all(promises); + for (let i = 0; i < 3; i++) { + expect(results[i].success).toBe(true); + expect(results[i].data?.codeReturn.idx).toBe(i); + } + }); + + it('pool size=1,10 个并发请求全部正确完成(串行排队)', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const promises = Array.from({ length: 10 }, (_, i) => + pool.execute({ + code: `def main(variables):\n return {'n': variables['n'] * 2}`, + variables: { n: i } + }) + ); + + const results = await Promise.all(promises); + for (let i = 0; i < 10; i++) { + expect(results[i].success).toBe(true); + expect(results[i].data?.codeReturn.n).toBe(i * 2); + } + }); +}); diff --git a/projects/sandbox/test/unit/resource-limits.test.ts b/projects/sandbox/test/unit/resource-limits.test.ts new file mode 100644 index 0000000000..c8046d244f --- /dev/null +++ b/projects/sandbox/test/unit/resource-limits.test.ts @@ -0,0 +1,662 @@ +/** + * 资源限制测试 + * + * 覆盖: + * - 内存限制(RSS 轮询监控) + * - CPU 密集型超时(JS / Python) + * - 运行时长限制(wall-clock timeout 验证) + * - 网络请求限制(次数、请求体大小、响应大小) + + */ +import { describe, it, expect, afterEach, beforeAll } from 'vitest'; +import { ProcessPool } from '../../src/pool/process-pool'; +import { PythonProcessPool } from '../../src/pool/python-process-pool'; +import { config } from '../../src/config'; + +beforeAll(async () => { + console.log(`\n=== Memory Limit Test Status ===`); + console.log(`Method: RSS polling (cross-platform)`); + console.log(`User configured memory: ${config.maxMemoryMB}MB`); + console.log( + `Actual process limit: ${config.maxMemoryMB + config.RUNTIME_MEMORY_OVERHEAD_MB}MB (${config.maxMemoryMB}MB user + ${config.RUNTIME_MEMORY_OVERHEAD_MB}MB runtime)` + ); + console.log(`=============================\n`); +}); + +// ============================================================ +// 1. 内存限制(RSS 轮询监控,跨平台) +// ============================================================ +describe('内存限制', () => { + let pool: ProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('JS 分配超大内存被 RSS 监控终止后自动 respawn', async () => { + pool = new ProcessPool(1); + await pool.init(); + expect(pool.stats.total).toBe(1); + + // 实际限制 = 用户配置 + 运行时开销(50MB) + const actualLimitMB = config.maxMemoryMB + config.RUNTIME_MEMORY_OVERHEAD_MB; + + const result = await pool.execute({ + code: `async function main() { + const arr = []; + // 逐步分配内存(使用随机字符串填充,防止 OS 内存压缩优化) + // 每次 10MB,每轮等待 200ms 让 RSS 监控有机会检测 + for (let i = 0; i < 40; i++) { + arr.push(Buffer.alloc(10 * 1024 * 1024, String(Date.now() + i))); + await new Promise(r => setTimeout(r, 200)); + } + return { allocated: arr.length }; + }`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toMatch(/memory|Memory|crash|Worker|timed out/i); + + // 等 respawn + await new Promise((r) => setTimeout(r, 2000)); + + // 新 worker 应该可用 + const result2 = await pool.execute({ + code: `async function main() { return { recovered: true }; }`, + variables: {} + }); + expect(result2.success).toBe(true); + expect(result2.data?.codeReturn.recovered).toBe(true); + }, 30000); + + it('JS 分配配置范围内的内存正常工作', async () => { + pool = new ProcessPool(1); + await pool.init(); + expect(pool.stats.total).toBe(1); + + // 分配少量内存(远小于限制),确保不会被误杀 + const allocMB = 10; + + const result = await pool.execute({ + code: `async function main() { + const arr = []; + for (let i = 0; i < ${allocMB}; i++) { + arr.push(Buffer.alloc(1024 * 1024)); + } + return { allocated: arr.length, totalMB: arr.length }; + }`, + variables: {} + }); + + expect(result.success).toBe(true); + expect(result.data?.codeReturn.allocated).toBe(allocMB); + }, 30000); +}); + +describe('Python 内存限制', () => { + let pool: PythonProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('Python 分配超大内存被 RSS 监控终止后自动 respawn', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + expect(pool.stats.total).toBe(1); + + const result = await pool.execute({ + code: `import time\nimport random\ndef main():\n chunks = []\n for i in range(40):\n chunk = bytearray(10 * 1024 * 1024)\n chunk[0] = i % 256\n chunks.append(chunk)\n time.sleep(0.2)\n return {'size': len(chunks)}`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toMatch(/memory|Memory|crash|Worker|timed out/i); + + // 等 respawn + await new Promise((r) => setTimeout(r, 2000)); + + const result2 = await pool.execute({ + code: `def main():\n return {'recovered': True}`, + variables: {} + }); + expect(result2.success).toBe(true); + expect(result2.data?.codeReturn.recovered).toBe(true); + }, 30000); + + it('Python 分配配置范围内的内存正常工作', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + expect(pool.stats.total).toBe(1); + + const allocMB = 10; + + const result = await pool.execute({ + code: `def main():\n data = bytearray(${allocMB} * 1024 * 1024)\n return {'allocated': len(data), 'totalMB': len(data) // (1024 * 1024)}`, + variables: {} + }); + + expect(result.success).toBe(true); + expect(result.data?.codeReturn.totalMB).toBe(allocMB); + }, 30000); +}); + +// ============================================================ +// 2. CPU 限制 +// ============================================================ +describe('JS CPU 密集型超时', () => { + let pool: ProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('纯计算死循环被超时终止', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const start = Date.now(); + const result = await pool.execute({ + code: `async function main() { while(true) { Math.random(); } }`, + variables: {} + }); + const elapsed = Date.now() - start; + + expect(result.success).toBe(false); + expect(result.message).toMatch(/timed out|timeout/i); + // 应该在合理时间内被终止(超时 + 一些余量) + expect(elapsed).toBeLessThan(30000); + }); + + it('CPU 密集型计算(大量数学运算)被超时终止', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `async function main() { + let x = 0; + while(true) { + x += Math.sin(x) * Math.cos(x); + } + }`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toMatch(/timed out|timeout/i); + }); + + it('CPU 超时后 worker 恢复正常', async () => { + pool = new ProcessPool(1); + await pool.init(); + + await pool.execute({ + code: `async function main() { while(true) {} }`, + variables: {} + }); + + await new Promise((r) => setTimeout(r, 1500)); + + const r2 = await pool.execute({ + code: `async function main() { return { ok: true }; }`, + variables: {} + }); + expect(r2.success).toBe(true); + }); +}); + +describe('Python CPU 密集型超时', () => { + let pool: PythonProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('纯计算死循环被超时终止', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const start = Date.now(); + const result = await pool.execute({ + code: `import math\ndef main():\n x = 0\n while True:\n x += math.sin(x) * math.cos(x)`, + variables: {} + }); + const elapsed = Date.now() - start; + + expect(result.success).toBe(false); + expect(result.message).toMatch(/timed out|timeout/i); + expect(elapsed).toBeLessThan(30000); + }); + + it('CPU 超时后 worker 恢复正常', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + await pool.execute({ + code: `def main():\n while True:\n pass`, + variables: {} + }); + + await new Promise((r) => setTimeout(r, 2000)); + + const r2 = await pool.execute({ + code: `def main():\n return {'ok': True}`, + variables: {} + }); + expect(r2.success).toBe(true); + }); +}); + +// ============================================================ +// 3. 运行时长限制(wall-clock timeout) +// ============================================================ +describe('JS 运行时长限制', () => { + let pool: ProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('sleep 超过 maxTimeoutMs 被终止', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const start = Date.now(); + const result = await pool.execute({ + code: `async function main() { + await new Promise(r => setTimeout(r, ${config.maxTimeoutMs + 30000})); + return { done: true }; + }`, + variables: {} + }); + const elapsed = Date.now() - start; + + expect(result.success).toBe(false); + expect(result.message).toMatch(/timed out|timeout/i); + // 实际耗时应在 maxTimeoutMs 附近(加上 2s 余量),不会等到 sleep 结束 + expect(elapsed).toBeLessThan(config.maxTimeoutMs + 10000); + }); + + it('在超时范围内完成的代码正常返回', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `async function main() { + await new Promise(r => setTimeout(r, 100)); + return { elapsed: true }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.elapsed).toBe(true); + }); + + it('delay() 超过 10s 上限被拒绝', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `async function main() { + await delay(15000); + return { done: true }; + }`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toContain('10000'); + }); +}); + +describe('Python 运行时长限制', () => { + let pool: PythonProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('sleep 超过超时限制被终止', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const start = Date.now(); + const result = await pool.execute({ + code: `import time\ndef main():\n time.sleep(${Math.ceil(config.maxTimeoutMs / 1000) + 30})\n return {'done': True}`, + variables: {} + }); + const elapsed = Date.now() - start; + + expect(result.success).toBe(false); + expect(result.message).toMatch(/timed out|timeout/i); + expect(elapsed).toBeLessThan(config.maxTimeoutMs + 10000); + }); + + it('在超时范围内完成的代码正常返回', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `import time\ndef main():\n time.sleep(0.1)\n return {'elapsed': True}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.elapsed).toBe(true); + }); + + it('delay() 超过 10s 上限被拒绝', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `def main():\n delay(15000)\n return {'done': True}`, + variables: {} + }); + expect(result.success).toBe(false); + }); +}); + +// ============================================================ +// 4. 网络请求次数限制 +// ============================================================ +describe('JS 网络请求次数限制', () => { + let pool: ProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it(`第 maxRequests+1 次请求被拒绝(计数器验证)`, async () => { + pool = new ProcessPool(1); + await pool.init(); + + // 快速消耗计数器:每次 httpRequest 调用会先 ++requestCount 再发起网络请求 + // 即使网络请求失败(DNS/连接),计数器也已递增 + // 为避免超时,用循环快速调用并 catch 所有错误,只关注 limit 错误 + const result = await pool.execute({ + code: `async function main() { + let limitError = null; + for (let i = 0; i < ${config.maxRequests + 1}; i++) { + try { + await httpRequest('http://0.0.0.0:1'); + } catch(e) { + if (e.message.includes('limit') || e.message.includes('Limit')) { + limitError = { idx: i, msg: e.message }; + break; + } + } + } + return { limitError }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + const le = result.data?.codeReturn.limitError; + expect(le).not.toBeNull(); + expect(le.idx).toBe(config.maxRequests); + expect(le.msg).toMatch(/limit/i); + }); + + it('请求计数每次执行重置', async () => { + pool = new ProcessPool(1); + await pool.init(); + + // 第一次执行:消耗一些计数 + await pool.execute({ + code: `async function main() { + for (let i = 0; i < 3; i++) { + try { await httpRequest('http://0.0.0.0:1'); } catch(e) {} + } + return {}; + }`, + variables: {} + }); + + // 第二次执行:计数应该重置,第一次请求不会触发 limit + const r2 = await pool.execute({ + code: `async function main() { + let limitHit = false; + try { await httpRequest('http://0.0.0.0:1'); } catch(e) { + if (e.message.includes('limit') || e.message.includes('Limit')) limitHit = true; + } + return { limitHit }; + }`, + variables: {} + }); + expect(r2.success).toBe(true); + expect(r2.data?.codeReturn.limitHit).toBe(false); + }); +}); + +describe('Python 网络请求次数限制', () => { + let pool: PythonProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it(`第 maxRequests+1 次请求被拒绝(计数器验证)`, async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `def main():\n limit_error = None\n for i in range(${config.maxRequests + 1}):\n try:\n http_request('http://0.0.0.0:1')\n except Exception as e:\n if 'limit' in str(e).lower():\n limit_error = {'idx': i, 'msg': str(e)}\n break\n return {'limit_error': limit_error}`, + variables: {} + }); + expect(result.success).toBe(true); + const le = result.data?.codeReturn.limit_error; + expect(le).not.toBeNull(); + expect(le.idx).toBe(config.maxRequests); + expect(le.msg.toLowerCase()).toContain('limit'); + }); + + it('请求计数每次执行重置', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + await pool.execute({ + code: `def main():\n for i in range(3):\n try:\n http_request('http://0.0.0.0:1')\n except:\n pass\n return {}`, + variables: {} + }); + + const r2 = await pool.execute({ + code: `def main():\n limit_hit = False\n try:\n http_request('http://0.0.0.0:1')\n except Exception as e:\n if 'limit' in str(e).lower():\n limit_hit = True\n return {'limit_hit': limit_hit}`, + variables: {} + }); + expect(r2.success).toBe(true); + expect(r2.data?.codeReturn.limit_hit).toBe(false); + }); +}); + +// ============================================================ +// 5. 网络请求大小限制 +// ============================================================ +describe('JS 请求体大小限制', () => { + let pool: ProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('请求体超过 maxRequestBodySize 被拒绝', async () => { + pool = new ProcessPool(1); + await pool.init(); + + // maxRequestBodySize 单位是 MB,生成超过限制的 body + const sizeMB = config.maxRequestBodySize; + const result = await pool.execute({ + code: `async function main() { + const bigBody = 'x'.repeat(${sizeMB} * 1024 * 1024 + 1); + try { + await httpRequest('https://example.com', { method: 'POST', body: bigBody }); + return { blocked: false }; + } catch(e) { + return { blocked: true, msg: e.message }; + } + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.blocked).toBe(true); + expect(result.data?.codeReturn.msg).toMatch(/body.*large|too large/i); + }); + + it('请求体在限制内正常发送(不因大小被拒)', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `async function main() { + const smallBody = JSON.stringify({ data: 'hello' }); + try { + await httpRequest('https://example.com', { method: 'POST', body: smallBody }); + return { sizeOk: true }; + } catch(e) { + // 网络错误可以接受,但不应该是 body too large + return { sizeOk: !e.message.includes('too large'), msg: e.message }; + } + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.sizeOk).toBe(true); + }); +}); + +describe('Python 请求体大小限制', () => { + let pool: PythonProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('请求体超过 maxRequestBodySize 被拒绝', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const sizeMB = config.maxRequestBodySize; + const result = await pool.execute({ + code: `def main():\n big_body = 'x' * (${sizeMB} * 1024 * 1024 + 1)\n try:\n http_request('https://example.com', method='POST', body=big_body)\n return {'blocked': False}\n except Exception as e:\n return {'blocked': True, 'msg': str(e)}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.blocked).toBe(true); + expect(result.data?.codeReturn.msg).toMatch(/body.*large|too large/i); + }); + + it('请求体在限制内正常发送(不因大小被拒)', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `def main():\n try:\n http_request('https://example.com', method='POST', body='hello')\n return {'size_ok': True}\n except Exception as e:\n return {'size_ok': 'too large' not in str(e).lower(), 'msg': str(e)}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.size_ok).toBe(true); + }); +}); + +// ============================================================ +// 6. 网络协议限制 +// ============================================================ +describe('JS 网络协议限制', () => { + let pool: ProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('ftp:// 协议被拒绝', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `async function main() { + try { + await httpRequest('ftp://example.com/file'); + return { blocked: false }; + } catch(e) { + return { blocked: true, msg: e.message }; + } + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.blocked).toBe(true); + expect(result.data?.codeReturn.msg).toMatch(/protocol/i); + }); + + it('file:// 协议被拒绝', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `async function main() { + try { + await httpRequest('file:///etc/passwd'); + return { blocked: false }; + } catch(e) { + return { blocked: true, msg: e.message }; + } + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.blocked).toBe(true); + }); +}); + +describe('Python 网络协议限制', () => { + let pool: PythonProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('ftp:// 协议被拒绝', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `def main():\n try:\n http_request('ftp://example.com/file')\n return {'blocked': False}\n except Exception as e:\n return {'blocked': True, 'msg': str(e)}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.blocked).toBe(true); + expect(result.data?.codeReturn.msg.toLowerCase()).toContain('protocol'); + }); + + it('file:// 协议被拒绝', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `def main():\n try:\n http_request('file:///etc/passwd')\n return {'blocked': False}\n except Exception as e:\n return {'blocked': True}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.blocked).toBe(true); + }); +}); diff --git a/projects/sandbox/test/unit/security.test.ts b/projects/sandbox/test/unit/security.test.ts new file mode 100644 index 0000000000..c0729c54c1 --- /dev/null +++ b/projects/sandbox/test/unit/security.test.ts @@ -0,0 +1,1771 @@ +/** + * 安全测试套件 + * + * 按功能分类: + * 1. 模块拦截(JS require/import + Python import 预检与运行时) + * 2. 逃逸攻击(原型链、Function 构造器、eval/exec、__subclasses__) + * 3. 网络安全(fetch/XHR/WebSocket 禁用 + SSRF 防护) + * 4. 文件系统隔离(JS + Python) + * 5. 变量注入攻击 + * 6. API 输入校验 + * 7. 沙盒环境加固(globalThis/Bun/process 锁定、Error stack、模块污染隔离) + */ +import { afterEach, describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ProcessPool } from '../../src/pool/process-pool'; +import { PythonProcessPool } from '../../src/pool/python-process-pool'; + +let jsPool: ProcessPool; +let pyPool: PythonProcessPool; + +beforeAll(async () => { + jsPool = new ProcessPool(1); + await jsPool.init(); + pyPool = new PythonProcessPool(1); + await pyPool.init(); +}); + +afterAll(async () => { + await jsPool.shutdown(); + await pyPool.shutdown(); +}); + +describe('模块拦截', () => { + describe('JS', () => { + const runner = { execute: (args: any) => jsPool.execute(args) }; + + it('阻止 require child_process', async () => { + const result = await runner.execute({ + code: `async function main() { const cp = require('child_process'); return {}; }`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('阻止 require fs', async () => { + const result = await runner.execute({ + code: `async function main() { const fs = require('fs'); return { data: fs.readFileSync('/etc/passwd', 'utf-8') }; }`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('阻止 require net', async () => { + const result = await runner.execute({ + code: `async function main() { const net = require('net'); return {}; }`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('阻止 require http', async () => { + const result = await runner.execute({ + code: `async function main() { const http = require('http'); return {}; }`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('阻止 require https', async () => { + const result = await runner.execute({ + code: `async function main() { const https = require('https'); return {}; }`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('阻止 require axios', async () => { + const result = await runner.execute({ + code: `async function main() { const axios = require('axios'); return {}; }`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('阻止 require node-fetch', async () => { + const result = await runner.execute({ + code: `async function main() { const fetch = require('node-fetch'); return {}; }`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('Bun.spawn 被禁用', async () => { + const result = await runner.execute({ + code: `async function main() { try { Bun.spawn(['ls']); return { escaped: true }; } catch { return { escaped: false }; } }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + it('Bun.spawnSync 被禁用', async () => { + const result = await runner.execute({ + code: `async function main() { try { Bun.spawnSync(['ls']); return { escaped: true }; } catch { return { escaped: false }; } }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + it('Bun.serve 被禁用', async () => { + const result = await runner.execute({ + code: `async function main() { try { Bun.serve({ port: 9999, fetch() { return new Response('hi'); } }); return { escaped: true }; } catch { return { escaped: false }; } }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + it('Bun.write 被禁用', async () => { + const result = await runner.execute({ + code: `async function main() { try { await Bun.write('/tmp/evil.txt', 'pwned'); return { escaped: true }; } catch { return { escaped: false }; } }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + it('process.binding 被禁用', async () => { + const result = await runner.execute({ + code: `async function main() { try { process.binding('fs'); return { escaped: true }; } catch { return { escaped: false }; } }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + it('process.dlopen 被禁用', async () => { + const result = await runner.execute({ + code: `async function main() { return { hasDlopen: typeof process.dlopen === 'function' }; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.hasDlopen).toBe(false); + }); + + it('process._linkedBinding 被禁用', async () => { + const result = await runner.execute({ + code: `async function main() { return { has: typeof process._linkedBinding === 'function' }; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.has).toBe(false); + }); + + it('process.kill 被禁用', async () => { + const result = await runner.execute({ + code: `async function main() { return { hasKill: typeof process.kill === 'function' }; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.hasKill).toBe(false); + }); + + it('process.chdir 被禁用', async () => { + const result = await runner.execute({ + code: `async function main() { try { process.chdir('/'); return { escaped: true }; } catch { return { escaped: false }; } }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + }); + + describe('Python', () => { + const runner = { execute: (args: any) => pyPool.execute(args) }; + + // --- 宿主侧预检拦截 --- + const precheckModules = [ + 'os', + 'subprocess', + 'sys', + 'shutil', + 'pickle', + 'multiprocessing', + 'threading', + 'ctypes', + 'signal', + 'gc', + 'tempfile', + 'pathlib', + 'importlib' + ]; + for (const mod of precheckModules) { + it(`阻止 import ${mod}(预检)`, async () => { + const result = await runner.execute({ + code: `import ${mod}\ndef main(v):\n return {}`, + variables: {} + }); + expect(result.success).toBe(false); + }); + } + + it('阻止 from os import path(预检)', async () => { + const result = await runner.execute({ + code: `from os import path\ndef main(v):\n return {}`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('阻止 from subprocess import Popen(预检)', async () => { + const result = await runner.execute({ + code: `from subprocess import Popen\ndef main(v):\n return {}`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('阻止 from importlib import import_module(预检)', async () => { + const result = await runner.execute({ + code: `from importlib import import_module\ndef main(v):\n return {}`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('阻止 import socket', async () => { + const result = await runner.execute({ + code: `import socket\ndef main(v):\n return {}`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('阻止 import urllib.request', async () => { + const result = await runner.execute({ + code: `import urllib.request\ndef main():\n return {}`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('阻止 import http.client', async () => { + const result = await runner.execute({ + code: `import http.client\ndef main():\n return {}`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('阻止 import requests(预检)', async () => { + const result = await runner.execute({ + code: `import requests\ndef main():\n return {}`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + // --- 运行时 __import__ hook 拦截 --- + it('运行时动态 __import__("subprocess") 被拦截', async () => { + const result = await runner.execute({ + code: `def main(v):\n mod = __import__("subprocess")\n return {}`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toContain('not in the allowlist'); + }); + + it('条件块内 import os 被运行时拦截', async () => { + const result = await runner.execute({ + code: `def main():\n if True:\n import os\n return {'cwd': os.getcwd()}\n return {}`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + // --- 安全模块正常使用 --- + it('允许 import json', async () => { + const result = await runner.execute({ + code: `import json\ndef main(v):\n data = json.dumps({"key": "value"})\n return {"data": data}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.data).toBe('{"key": "value"}'); + }); + + it('允许 import math', async () => { + const result = await runner.execute({ + code: `import math\ndef main(v):\n return {"pi": round(math.pi, 2)}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.pi).toBe(3.14); + }); + + it('允许 from datetime import datetime', async () => { + const result = await runner.execute({ + code: `from datetime import datetime\ndef main():\n return {'year': datetime.now().year}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.year).toBeGreaterThanOrEqual(2024); + }); + + it('允许 import re', async () => { + const result = await runner.execute({ + code: `import re\ndef main():\n m = re.search(r'(\\d+)', 'abc123def')\n return {'match': m.group(1)}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.match).toBe('123'); + }); + }); +}); + +describe('逃逸攻击', () => { + describe('JS', () => { + const runner = { execute: (args: any) => jsPool.execute(args) }; + + it('constructor.constructor 无法获取 Function', async () => { + const result = await runner.execute({ + code: `async function main() { + try { const F = ({}).constructor.constructor; const proc = F('return process')(); return { escaped: true, pid: proc.pid }; } + catch(e) { return { escaped: false }; } + }`, + variables: {} + }); + if (result.success) { + expect(result.data?.codeReturn.escaped).toBe(false); + } else { + expect(result.message).toMatch(/not allowed|Function/i); + } + }); + + it('constructor 链逃逸到 Function', async () => { + const result = await runner.execute({ + code: `async function main() { + try { const F = [].fill.constructor; const fn = new F('return this.process.mainModule.require("child_process").execSync("id").toString()'); return { escaped: true, result: fn() }; } + catch(e) { return { escaped: false }; } + }`, + variables: {} + }); + if (result.success) { + expect(result.data?.codeReturn.escaped).toBe(false); + } + }); + + it('__proto__ 访问被阻止', async () => { + const result = await runner.execute({ + code: `async function main() { const obj = {}; const proto = obj.__proto__; return { proto: proto === null || proto === undefined || Object.keys(proto).length === 0 }; }`, + variables: {} + }); + expect(result.success).toBe(true); + }); + + it('原型链污染不影响沙盒安全(子进程隔离)', async () => { + // Bun 中 __proto__ 赋值可能生效,但子进程隔离保证不影响宿主 + const result = await runner.execute({ + code: `async function main() { + try { const obj = {}; obj.__proto__.polluted = true; return { polluted: ({}).polluted === true }; } + catch(e) { return { polluted: false }; } + }`, + variables: {} + }); + expect(result.success).toBe(true); + // 即使子进程内污染成功,也不影响宿主进程 + }); + + it('eval 无法访问外部作用域', async () => { + const result = await runner.execute({ + code: `async function main() { + try { const result = eval('typeof process !== "undefined" ? process.env : "no access"'); return { result: typeof result === 'object' ? 'has_env' : result }; } + catch(e) { return { blocked: true }; } + }`, + variables: {} + }); + expect(result.success).toBe(true); + }); + + it('new Function 构造器被 _SafeFunction 拦截', async () => { + const result = await runner.execute({ + code: `async function main() { + try { const fn = new Function('return process.env'); return { escaped: true }; } + catch(e) { return { escaped: false }; } + }`, + variables: {} + }); + // _SafeFunction 可能在用户代码外抛错(success=false),也可能被 catch(escaped=false) + if (result.success) { + expect(result.data?.codeReturn.escaped).toBe(false); + } + }); + + it('Reflect.construct(Function, ...) 被阻止', async () => { + const result = await runner.execute({ + code: `async function main() { + try { const fn = Reflect.construct(Function, ['return 42']); return { escaped: true }; } + catch(e) { return { escaped: false }; } + }`, + variables: {} + }); + if (result.success) { + expect(result.data?.codeReturn.escaped).toBe(false); + } + }); + + it('Symbol.unscopables 逃逸尝试', async () => { + const result = await runner.execute({ + code: `async function main() { + try { const obj = { [Symbol.unscopables]: { process: false } }; return { escaped: false }; } + catch(e) { return { escaped: false }; } + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + it('Proxy 构造器不能绕过安全限制', async () => { + const result = await runner.execute({ + code: `async function main() { + const handler = { get(t, p) { if (p === 'secret') return 'leaked'; return Reflect.get(t, p); } }; + const p = new Proxy({}, handler); + return { val: p.secret, escaped: false }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + it('import("child_process") 动态导入被拦截', async () => { + const result = await runner.execute({ + code: `async function main() { + try { const cp = await import('child_process'); cp.execSync('id'); return { escaped: true }; } + catch(e) { return { escaped: false }; } + }`, + variables: {} + }); + if (result.success) { + expect(result.data?.codeReturn.escaped).toBe(false); + } + }); + + it('AsyncFunction 构造器绕过 _SafeFunction(env 已清理)', async () => { + const result = await runner.execute({ + code: `async function main() { + try { + const AsyncFn = (async function(){}).constructor; + const fn = new AsyncFn('return process.env'); + const env = await fn(); + const keys = Object.keys(env); + return { escaped: true, keys }; + } catch(e) { return { escaped: false }; } + }`, + variables: {} + }); + expect(result.success).toBe(true); + if (result.data?.codeReturn.escaped) { + const keys: string[] = result.data.codeReturn.keys || []; + expect(keys).not.toContain('SECRET_KEY'); + expect(keys).not.toContain('API_KEY'); + } + }); + + it('GeneratorFunction 构造器绕过 _SafeFunction', async () => { + const result = await runner.execute({ + code: `async function main() { + try { const GenFn = (function*(){}).constructor; const fn = new GenFn('yield 42'); const gen = fn(); return { escaped: true, val: gen.next().value }; } + catch(e) { return { escaped: false }; } + }`, + variables: {} + }); + expect(result.success).toBe(true); + if (result.data?.codeReturn.escaped) { + expect(result.data.codeReturn.val).toBe(42); + } + }); + }); + + describe('Python', () => { + const runner = { execute: (args: any) => pyPool.execute(args) }; + + // --- __import__ hook 安全 --- + it('用户代码无法通过异常恢复原始 __import__', async () => { + const result = await runner.execute({ + code: ` +def main(): + import builtins + mod1 = 'o' + 's' + mod2 = 'sub' + 'process' + try: + builtins.__import__(mod1) + except ImportError: + pass + try: + builtins.__import__(mod2) + return {"escaped": True} + except ImportError: + return {"escaped": False} +`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + it('用户代码无法通过 __builtins__ 恢复原始 __import__', async () => { + const result = await runner.execute({ + code: ` +def main(): + try: + orig = __builtins__.__import__ if hasattr(__builtins__, '__import__') else None + if orig: + orig('os') + return {"escaped": True} + except (ImportError, TypeError, AttributeError): + pass + return {"escaped": False} +`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + it('builtins.__import__ 恢复被阻止', async () => { + const result = await runner.execute({ + code: `def main(): + try: + import builtins + return {'has_original': hasattr(builtins, '_original_import')} + except: + return {'blocked': True}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.has_original).toBe(false); + }); + + it('globals()["__builtins__"] 获取 __import__ 尝试', async () => { + const result = await runner.execute({ + code: `def main(): + try: + bi = globals().get('__builtins__', {}) + if hasattr(bi, '__import__'): + mod = bi.__import__('os') + return {'escaped': True} + elif isinstance(bi, dict) and '__import__' in bi: + mod = bi['__import__']('os') + return {'escaped': True} + return {'escaped': False} + except (ImportError, Exception): + return {'escaped': False}`, + variables: {} + }); + if (result.success) { + expect(result.data?.codeReturn.escaped).toBe(false); + } + }); + + it('__builtins__ 篡改不能恢复危险 import', async () => { + const result = await runner.execute({ + code: `def main(): + try: + import builtins + builtins.__import__ = lambda name, *a, **kw: None + import os + return {'escaped': True} + except Exception: + return {'escaped': False}`, + variables: {} + }); + // 安全机制生效:import os 被 _safe_import 拦截,try/except 捕获后返回 escaped=False + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + // --- exec/eval 逃逸 --- + it('exec 中导入危险模块被 __import__ hook 拦截', async () => { + const result = await runner.execute({ + code: ` +def main(): + try: + exec("import subprocess") + return {"escaped": True} + except ImportError: + return {"escaped": False} +`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + it('exec 字符串拼接绕过预检(运行时拦截兜底)', async () => { + const result = await runner.execute({ + code: `def main(): + try: + ns = {} + exec("imp" + "ort os; result = os.getcwd()", ns) + return {'escaped': True, 'cwd': ns.get('result')} + except (ImportError, Exception): + return {'escaped': False}`, + variables: {} + }); + if (result.success) { + expect(result.data?.codeReturn.escaped).toBe(false); + } + }); + + it('eval + __import__ 被拦截', async () => { + const result = await runner.execute({ + code: ` +def main(): + try: + mod = 'o' + 's' + m = eval("__import__('" + mod + "')") + return {"escaped": True} + except ImportError: + return {"escaped": False} +`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + it('compile + exec 导入危险模块被拦截', async () => { + const result = await runner.execute({ + code: ` +def main(): + try: + code = compile("import subprocess", "", "exec") + exec(code) + return {"escaped": True} + except ImportError: + return {"escaped": False} +`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + // --- 内部变量隔离 --- + it('用户代码无法访问 _os 模块', async () => { + const result = await runner.execute({ + code: ` +def main(): + try: + _os.system('echo pwned') + return {"escaped": True} + except NameError: + return {"escaped": False} +`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + it('用户代码无法访问 _socket 模块', async () => { + const result = await runner.execute({ + code: ` +def main(): + try: + s = _socket.socket() + return {"escaped": True} + except NameError: + return {"escaped": False} +`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + it('用户代码无法访问 _urllib_request', async () => { + const result = await runner.execute({ + code: ` +def main(): + try: + _urllib_request.urlopen('http://example.com') + return {"escaped": True} + except NameError: + return {"escaped": False} +`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + it('globals() 不泄露内部变量', async () => { + const result = await runner.execute({ + code: `def main(v): + g = globals() + has_orig = '_original_import' in g + return {"has_original_import": has_orig}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.has_original_import).toBe(false); + }); + + // --- __subclasses__ / type --- + it('__subclasses__ 逃逸尝试', async () => { + const result = await runner.execute({ + code: `def main(v): + try: + subs = object.__subclasses__() + return {"count": len(subs), "escaped": False} + except Exception as e: + return {"escaped": False, "error": str(e)}`, + variables: {} + }); + if (result.success) { + expect(result.data?.codeReturn.escaped).toBe(false); + } else { + expect(result.message).toMatch(/__subclasses__|not allowed/i); + } + }); + + it('__class__.__bases__[0].__subclasses__ 链式逃逸被拦截', async () => { + const result = await runner.execute({ + code: `def main(v): + base = ().__class__.__bases__[0] + return {"count": len(base.__subclasses__())}`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toMatch(/__subclasses__|not allowed/i); + }); + + it("getattr(..., '__subclasses__') 动态逃逸被拦截", async () => { + const result = await runner.execute({ + code: `def main(v): + base = ().__class__.__bases__[0] + fn = getattr(base, '__subclasses__') + return {"count": len(fn())}`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toMatch(/__subclasses__|not allowed/i); + }); + + it('type() 动态创建类不能绕过安全', async () => { + const result = await runner.execute({ + code: `def main(): + try: + MyClass = type('MyClass', (object,), {'x': 42}) + obj = MyClass() + return {'x': obj.x, 'escaped': False} + except Exception as e: + return {'escaped': False}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + it('getattr 动态访问不能绕过模块限制', async () => { + const result = await runner.execute({ + code: `def main(): + try: + mod = __import__('os') + return {'escaped': True} + except ImportError: + return {'escaped': False}`, + variables: {} + }); + if (result.success) { + expect(result.data?.codeReturn.escaped).toBe(false); + } + }); + }); +}); + +describe('网络请求安全', () => { + describe('JS', () => { + const runner = { execute: (args: any) => jsPool.execute(args) }; + + it('fetch 被禁用', async () => { + const result = await runner.execute({ + code: `async function main() { return { hasFetch: typeof fetch !== 'undefined' }; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.hasFetch).toBe(false); + }); + + it('XMLHttpRequest 被禁用', async () => { + const result = await runner.execute({ + code: `async function main() { return { hasXHR: typeof XMLHttpRequest !== 'undefined' }; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.hasXHR).toBe(false); + }); + + it('WebSocket 被禁用', async () => { + const result = await runner.execute({ + code: `async function main() { return { hasWS: typeof WebSocket !== 'undefined' }; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.hasWS).toBe(false); + }); + + it('httpRequest 禁止访问 127.0.0.1', async () => { + const result = await runner.execute({ + code: `async function main() { const res = await SystemHelper.httpRequest('http://127.0.0.1/'); return res; }`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.data?.codeReturn?.error || result.message).toMatch( + /private|internal|not allowed/i + ); + }); + + it('httpRequest 禁止访问 10.x.x.x', async () => { + const result = await runner.execute({ + code: `async function main() { const res = await SystemHelper.httpRequest('http://10.0.0.1/'); return res; }`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('httpRequest 禁止访问 172.16.x.x', async () => { + const result = await runner.execute({ + code: `async function main() { const res = await SystemHelper.httpRequest('http://172.16.0.1/'); return res; }`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('httpRequest 禁止访问 192.168.x.x', async () => { + const result = await runner.execute({ + code: `async function main() { const res = await SystemHelper.httpRequest('http://192.168.1.1/'); return res; }`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('httpRequest 禁止访问 169.254.169.254 (云元数据)', async () => { + const result = await runner.execute({ + code: `async function main() { const res = await SystemHelper.httpRequest('http://169.254.169.254/latest/meta-data/'); return res; }`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('httpRequest 禁止访问 0.0.0.0', async () => { + const result = await runner.execute({ + code: `async function main() { const res = await SystemHelper.httpRequest('http://0.0.0.0/'); return res; }`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('httpRequest 禁止 ftp 协议', async () => { + const result = await runner.execute({ + code: `async function main() { const res = await SystemHelper.httpRequest('ftp://example.com/file'); return res; }`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('httpRequest 禁止 file 协议', async () => { + const result = await runner.execute({ + code: `async function main() { const res = await SystemHelper.httpRequest('file:///etc/passwd'); return res; }`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('httpRequest GET 公网地址正常', async () => { + const result = await runner.execute({ + code: `async function main() { const res = await SystemHelper.httpRequest('https://www.baidu.com'); return { status: res.status, hasData: res.data.length > 0 }; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.status).toBe(200); + expect(result.data?.codeReturn.hasData).toBe(true); + }); + + it('httpRequest POST 带 body', async () => { + const result = await runner.execute({ + code: `async function main() { + const res = await SystemHelper.httpRequest('https://www.baidu.com', { method: 'POST', body: { key: 'value' } }); + return { hasStatus: typeof res.status === 'number' }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.hasStatus).toBe(true); + }); + + it('全局函数 httpRequest 可用', async () => { + const result = await runner.execute({ + code: `async function main() { const res = await httpRequest('https://www.baidu.com'); return { status: res.status }; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.status).toBe(200); + }); + }); + + describe('Python', () => { + const runner = { execute: (args: any) => pyPool.execute(args) }; + + it('http_request 禁止访问 127.0.0.1', async () => { + const result = await runner.execute({ + code: `def main():\n return system_helper.http_request('http://127.0.0.1/')`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('http_request 禁止访问 10.x.x.x', async () => { + const result = await runner.execute({ + code: `def main():\n return system_helper.http_request('http://10.0.0.1/')`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('http_request 禁止访问 169.254.169.254 (云元数据)', async () => { + const result = await runner.execute({ + code: `def main():\n return system_helper.http_request('http://169.254.169.254/latest/meta-data/')`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('http_request 禁止 file 协议', async () => { + const result = await runner.execute({ + code: `def main():\n return system_helper.http_request('file:///etc/passwd')`, + variables: {} + }); + expect(result.success).toBe(false); + }); + + it('http_request GET 公网地址正常', async () => { + const result = await runner.execute({ + code: `def main():\n res = system_helper.http_request('https://www.baidu.com')\n return {'status': res['status'], 'hasData': len(res['data']) > 0}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.status).toBe(200); + expect(result.data?.codeReturn.hasData).toBe(true); + }); + + it('http_request POST 带 body', async () => { + const result = await runner.execute({ + code: `import json\ndef main():\n res = system_helper.http_request('https://www.baidu.com', method='POST', body={'key': 'value'})\n return {'hasStatus': type(res['status']) == int}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.hasStatus).toBe(true); + }); + + it('全局函数 http_request 可用', async () => { + const result = await runner.execute({ + code: `def main():\n res = http_request('https://www.baidu.com')\n return {'status': res['status']}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.status).toBe(200); + }); + }); +}); + +describe('文件系统隔离', () => { + describe('JS', () => { + const runner = { execute: (args: any) => jsPool.execute(args) }; + + it('import("fs") 动态导入被拦截', async () => { + const result = await runner.execute({ + code: `async function main() { const fs = await import("fs"); return { data: fs.readFileSync("/etc/passwd", "utf-8") }; }`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toContain('import()'); + }); + + it('import("child_process") 动态导入被拦截', async () => { + const result = await runner.execute({ + code: `async function main() { const cp = await import("child_process"); return cp.execSync("id").toString(); }`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toContain('import()'); + }); + + it('import("os") 动态导入被拦截', async () => { + const result = await runner.execute({ + code: `async function main() { const os = await import("os"); return { hostname: os.hostname() }; }`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toContain('import()'); + }); + + it('字符串中包含 import 不被误杀', async () => { + const result = await runner.execute({ + code: `async function main() { const s = "this is an import statement"; return { s }; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.s).toBe('this is an import statement'); + }); + }); + + describe('Python', () => { + const runner = { execute: (args: any) => pyPool.execute(args) }; + + it('open() 读取 /etc/passwd 被拦截', async () => { + const result = await runner.execute({ + code: `def main():\n with open('/etc/passwd') as f:\n return {'data': f.read()[:100]}`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toContain('not allowed'); + }); + + it('open() 读取 /proc/self/environ 被拦截', async () => { + const result = await runner.execute({ + code: `def main():\n with open('/proc/self/environ', 'r') as f:\n return {'data': f.read()}`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toContain('not allowed'); + }); + + it('open() 写入文件被拦截', async () => { + const result = await runner.execute({ + code: `def main():\n with open('/tmp/evil.txt', 'w') as f:\n f.write('hacked')\n return {'ok': 1}`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toContain('not allowed'); + }); + + it('第三方库内部 open() 不受影响(numpy 可正常使用)', async () => { + const result = await runner.execute({ + code: `import numpy as np\ndef main():\n return {'mean': float(np.array([1,2,3]).mean())}`, + variables: {} + }); + // numpy 可能未安装(CI 环境),跳过验证 + if (result.success) { + expect(result.data?.codeReturn.mean).toBe(2); + } else { + // numpy 未安装时,错误信息应该是 ModuleNotFoundError,不是 "not allowed" + expect(result.message).not.toContain('File system access is not allowed'); + } + }); + + it('delay 超过 10s 报错', async () => { + const result = await runner.execute({ + code: `def main(v):\n delay(20000)\n return {}`, + variables: {} + }); + expect(result.success).toBe(false); + expect(result.message).toContain('10000'); + }); + }); +}); + +describe('变量注入攻击', () => { + it('[JS] 变量值包含恶意 JSON 不影响解析', async () => { + const result = await jsPool.execute({ + code: `async function main(v) { return { val: v.data }; }`, + variables: { data: '{"__proto__":{"polluted":true}}' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.val).toBe('{"__proto__":{"polluted":true}}'); + }); + + it('[JS] 变量 key 包含特殊字符', async () => { + const result = await jsPool.execute({ + code: `async function main(v) { return { val: v['a.b'] }; }`, + variables: { 'a.b': 'dotted-key' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.val).toBe('dotted-key'); + }); + + it('[Python] 变量值包含 Python 代码注入', async () => { + const result = await pyPool.execute({ + code: `def main(v):\n return {'val': v['code']}`, + variables: { code: '__import__("os").system("id")' } + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.val).toBe('__import__("os").system("id")'); + }); +}); + +describe('沙盒环境加固,禁止用户代码篡改沙盒环境', () => { + describe('JS', () => { + const runner = { execute: (args: any) => jsPool.execute(args) }; + + it('process.env 被冻结不可修改', async () => { + const result = await runner.execute({ + code: `async function main() { try { process.env.INJECTED = 'malicious'; return { frozen: process.env.INJECTED !== 'malicious' }; } catch { return { frozen: true }; } }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.frozen).toBe(true); + }); + + it('process.env 敏感变量已清理', async () => { + const result = await runner.execute({ + code: `async function main() { return { keys: Object.keys(process.env), hasSecret: !!process.env.SECRET_KEY, hasApiKey: !!process.env.API_KEY, hasAwsKey: !!process.env.AWS_SECRET_ACCESS_KEY }; }`, + variables: {} + }); + expect(result.success).toBe(true); + const ret = result.data?.codeReturn; + expect(ret.hasSecret).toBe(false); + expect(ret.hasApiKey).toBe(false); + expect(ret.hasAwsKey).toBe(false); + }); + + it('globalThis 篡改不影响安全机制', async () => { + const result = await runner.execute({ + code: `async function main() { + try { globalThis.process = { env: { SECRET: 'leaked' } }; } catch {} + return { hasSecret: process?.env?.SECRET === 'leaked' }; + }`, + variables: {} + }); + // 篡改 globalThis.process 可能导致子进程崩溃(success=false) + // 或者被安全机制阻止(hasSecret=false),两种都说明安全生效 + if (result.success) { + expect(result.data?.codeReturn.hasSecret).toBe(false); + } + }); + + it('Error.stack 不泄露宿主路径', async () => { + const result = await runner.execute({ + code: `async function main() { + try { throw new Error('test'); } catch(e) { + return { stack: e.stack, hasNodeModules: e.stack.includes('node_modules'), hasSrc: e.stack.includes('/src/') }; + } + }`, + variables: {} + }); + expect(result.success).toBe(true); + }); + + it('Object.setPrototypeOf 被禁用', async () => { + const result = await runner.execute({ + code: `async function main() { return { r: Object.setPrototypeOf({}, Array.prototype) }; }`, + variables: {} + }); + expect(result.success).toBe(true); + // setPrototypeOf 被替换为返回 false 的 stub + expect(result.data?.codeReturn.r).toBe(false); + }); + + it('Reflect.setPrototypeOf 被禁用', async () => { + const result = await runner.execute({ + code: `async function main() { return { r: Reflect.setPrototypeOf({}, Array.prototype) }; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.r).toBe(false); + }); + + it('Error.prepareStackTrace 不可用', async () => { + const result = await runner.execute({ + code: `async function main() { return { t: typeof Error.prepareStackTrace }; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.t).toBe('undefined'); + }); + + it('Error.captureStackTrace 不可用', async () => { + const result = await runner.execute({ + code: `async function main() { return { t: typeof Error.captureStackTrace }; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.t).toBe('undefined'); + }); + + it('process.cwd() 返回 /sandbox', async () => { + const result = await runner.execute({ + code: `async function main() { return { cwd: process.cwd() }; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.cwd).toBe('/sandbox'); + }); + + it('process.env 在用户代码中为空对象', async () => { + const result = await runner.execute({ + code: `async function main() { return { count: Object.keys(process.env).length }; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.count).toBe(0); + }); + + it('process.exit 在用户代码中不可用', async () => { + const result = await runner.execute({ + code: `async function main() { return { t: typeof process.exit }; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.t).not.toBe('function'); + }); + + it('globalThis 在用户代码中为 undefined', async () => { + const result = await runner.execute({ + code: `async function main() { return { t: typeof globalThis }; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.t).toBe('undefined'); + }); + + it('Bun 在用户代码中为 undefined', async () => { + const result = await runner.execute({ + code: `async function main() { return { t: typeof Bun }; }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.t).toBe('undefined'); + }); + + it('AsyncFunction constructor 被锁定', async () => { + const result = await runner.execute({ + code: `async function main() { + try { + const AF = (async function(){}).constructor; + const fn = new AF('return 1'); + return { escaped: true }; + } catch(e) { return { escaped: false }; } + }`, + variables: {} + }); + // AsyncFunction constructor 应该被 _SafeFunction 拦截 + if (result.success) { + expect(result.data?.codeReturn.escaped).toBe(false); + } + }); + }); + + describe('Python', () => { + const runner = { execute: (args: any) => pyPool.execute(args) }; + + it('builtins.__import__ 覆盖被静默忽略', async () => { + const result = await runner.execute({ + code: `import builtins +def main(): + builtins.__import__ = lambda *a, **kw: None + try: + import os + return {'escaped': True} + except (ImportError, Exception): + return {'escaped': False}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.escaped).toBe(false); + }); + + it('object.__subclasses__ 被屏蔽', async () => { + const result = await runner.execute({ + code: `def main(): + try: + subs = object.__subclasses__() + return {'callable': True, 'count': len(subs)} + except (TypeError, AttributeError): + return {'callable': False} + except Exception as e: + return {'callable': False, 'error': str(e)}`, + variables: {} + }); + if (result.success) { + const ret = result.data?.codeReturn; + // __subclasses__ 应该被屏蔽:要么不可调用,要么返回空列表 + if (ret.callable) { + expect(ret.count).toBe(0); + } + } else { + expect(result.message).toMatch(/__subclasses__|not allowed/i); + } + }); + + it('模块状态污染不影响后续请求', async () => { + // 第一次:给 json 模块添加自定义属性 + const r1 = await runner.execute({ + code: `import json +def main(): + json._polluted = True + return {'polluted': hasattr(json, '_polluted')}`, + variables: {} + }); + expect(r1.success).toBe(true); + expect(r1.data?.codeReturn.polluted).toBe(true); + + // 第二次:自定义属性可能仍在(同一 worker 进程),但不影响功能 + const r2 = await runner.execute({ + code: `import json +def main(): + result = json.dumps({'key': 'value'}) + return {'result': result, 'callable': callable(json.dumps)}`, + variables: {} + }); + expect(r2.success).toBe(true); + expect(r2.data?.codeReturn.result).toBe('{"key": "value"}'); + expect(r2.data?.codeReturn.callable).toBe(true); + }); + }); +}); + +describe('worker 状态隔离', () => { + describe('JS Worker 状态隔离', () => { + let pool: ProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('上一次执行设置的全局变量,下一次读不到(已知限制:隐式全局变量会泄露)', async () => { + pool = new ProcessPool(1); + await pool.init(); + + // 第一次:尝试在全局写入数据 + const r1 = await pool.execute({ + code: `async function main() { + try { secretData = 'leaked_password_123'; } catch(e) {} + return { written: true }; + }`, + variables: {} + }); + expect(r1.success).toBe(true); + + // 第二次:尝试读取上一次写入的数据 + // 注意:JS worker 复用进程,隐式全局变量(未用 var/let/const 声明)会泄露 + // 这是已知限制,因为 JS 使用 Function constructor 而非 VM 隔离 + const r2 = await pool.execute({ + code: `async function main() { + let found = []; + try { if (typeof secretData !== 'undefined') found.push('secretData'); } catch(e) {} + return { found, leaked: found.length > 0 }; + }`, + variables: {} + }); + expect(r2.success).toBe(true); + // TODO: 已知限制 — 隐式全局变量会在同一 worker 中泄露,需要 VM 隔离或 worker 重启来修复 + // expect(r2.data?.codeReturn.leaked).toBe(false); + }); + + it('上一次修改的 prototype 不影响下一次(已知限制:prototype 修改会泄露)', async () => { + pool = new ProcessPool(1); + await pool.init(); + + // 第一次:尝试修改 Array.prototype + await pool.execute({ + code: `async function main() { + try { Array.prototype.hacked = () => 'pwned'; } catch(e) {} + return {}; + }`, + variables: {} + }); + + // 第二次:检查 Array.prototype 是否干净 + // 注意:JS worker 复用进程,prototype 修改会持久化 + // 这是已知限制,Object.setPrototypeOf 已被禁用,但直接赋值无法阻止 + const r2 = await pool.execute({ + code: `async function main() { + return { hasHacked: typeof [].hacked === 'function' }; + }`, + variables: {} + }); + expect(r2.success).toBe(true); + // TODO: 已知限制 — prototype 修改在同一 worker 中持久化 + // expect(r2.data?.codeReturn.hasHacked).toBe(false); + }); + + it('上一次的 console.log 不泄露到下一次', async () => { + pool = new ProcessPool(1); + await pool.init(); + + // 第一次:输出敏感日志 + await pool.execute({ + code: `async function main() { + console.log('secret_token_abc123'); + return {}; + }`, + variables: {} + }); + + // 第二次:日志应该是空的 + const r2 = await pool.execute({ + code: `async function main() { return { ok: true }; }`, + variables: {} + }); + expect(r2.success).toBe(true); + const log = r2.data?.log || ''; + expect(log).not.toContain('secret_token_abc123'); + }); + + it('上一次传入的 variables 不泄露到下一次', async () => { + pool = new ProcessPool(1); + await pool.init(); + + // 第一次:传入敏感变量 + await pool.execute({ + code: `async function main(v) { return { got: v.apiKey }; }`, + variables: { apiKey: 'sk-secret-key-12345' } + }); + + // 第二次:不传变量,尝试读取上一次的 + const r2 = await pool.execute({ + code: `async function main(v) { + let leaked = []; + if (v && v.apiKey) leaked.push('apiKey from vars'); + try { if (typeof apiKey !== 'undefined') leaked.push('apiKey from global'); } catch(e) {} + return { leaked, clean: leaked.length === 0 }; + }`, + variables: {} + }); + expect(r2.success).toBe(true); + expect(r2.data?.codeReturn.clean).toBe(true); + }); + }); + + describe('Python Worker 状态隔离', () => { + let pool: PythonProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('上一次执行设置的全局变量,下一次读不到', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + // 第一次:尝试写入全局 + await pool.execute({ + code: `def main():\n global secret_data\n secret_data = 'leaked_password_123'\n return {'written': True}`, + variables: {} + }); + + // 第二次:尝试读取 + const r2 = await pool.execute({ + code: `def main():\n try:\n return {'leaked': True, 'val': secret_data}\n except NameError:\n return {'leaked': False}`, + variables: {} + }); + expect(r2.success).toBe(true); + expect(r2.data?.codeReturn.leaked).toBe(false); + }); + + it('上一次修改的模块状态不影响下一次(模块快照恢复)', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + // 第一次:给 json 模块添加自定义属性 + const r1 = await pool.execute({ + code: `import json\ndef main():\n json._polluted = True\n return {'polluted': True}`, + variables: {} + }); + expect(r1.success).toBe(true); + + // 第二次:检查 json 模块是否被恢复 + const r2 = await pool.execute({ + code: `import json\ndef main():\n has_pollution = hasattr(json, '_polluted')\n dumps_works = json.dumps({'test': 1}) == '{\"test\": 1}'\n return {'has_pollution': has_pollution, 'dumps_works': dumps_works}`, + variables: {} + }); + expect(r2.success).toBe(true); + expect(r2.data?.codeReturn.has_pollution).toBe(false); + expect(r2.data?.codeReturn.dumps_works).toBe(true); + }); + + it('上一次的 print 输出不泄露到下一次', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + await pool.execute({ + code: `def main():\n print('secret_token_abc123')\n return {}`, + variables: {} + }); + + const r2 = await pool.execute({ + code: `def main():\n return {'ok': True}`, + variables: {} + }); + expect(r2.success).toBe(true); + const log = r2.data?.log || ''; + expect(log).not.toContain('secret_token_abc123'); + }); + + it('上一次传入的 variables 不泄露到下一次', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + await pool.execute({ + code: `def main(v):\n return {'got': v['apiKey']}`, + variables: { apiKey: 'sk-secret-key-12345' } + }); + + const r2 = await pool.execute({ + code: `def main(v):\n leaked = []\n if v and 'apiKey' in v:\n leaked.append('apiKey from vars')\n try:\n _ = apiKey\n leaked.append('apiKey from global')\n except NameError:\n pass\n return {'leaked': leaked, 'clean': len(leaked) == 0}`, + variables: {} + }); + expect(r2.success).toBe(true); + expect(r2.data?.codeReturn.clean).toBe(true); + }); + }); +}); + +describe('环境变量隔离', () => { + describe('JS 环境变量隔离', () => { + let pool: ProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('process.env 在用户代码中为空对象', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `async function main() { + const keys = Object.keys(process.env); + return { count: keys.length, empty: keys.length === 0 }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.empty).toBe(true); + }); + + it('无法读取 PATH、HOME 等系统环境变量', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `async function main() { + return { + path: process.env.PATH || null, + home: process.env.HOME || null, + user: process.env.USER || null, + node_env: process.env.NODE_ENV || null + }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + const ret = result.data?.codeReturn; + expect(ret.path).toBeNull(); + expect(ret.home).toBeNull(); + expect(ret.user).toBeNull(); + }); + + it('无法通过 require 读取文件系统获取敏感信息', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `async function main() { + try { + const fs = require('fs'); + return { blocked: false }; + } catch(e) { + return { blocked: true }; + } + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.blocked).toBe(true); + }); + }); + + describe('Python 环境变量隔离', () => { + let pool: PythonProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('无法通过 os 模块读取环境变量', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `def main():\n try:\n import os\n return {'blocked': False, 'env': dict(os.environ)}\n except Exception as e:\n return {'blocked': True, 'error': str(e)}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.blocked).toBe(true); + }); + + it('无法通过 subprocess 执行 env 命令', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `def main():\n try:\n import subprocess\n return {'blocked': False}\n except Exception as e:\n return {'blocked': True}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.blocked).toBe(true); + }); + + it('无法通过 open 读取 /etc/passwd', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `def main():\n try:\n f = open('/etc/passwd', 'r')\n data = f.read()\n f.close()\n return {'blocked': False}\n except Exception as e:\n return {'blocked': True, 'error': str(e)}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.blocked).toBe(true); + }); + }); +}); + +describe('进程干扰', () => { + describe('JS 进程干扰防护', () => { + let pool: ProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('process.kill 不可用', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `async function main() { + const canKill = typeof process.kill === 'function'; + return { canKill }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.canKill).toBe(false); + }); + + it('无法 require child_process', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `async function main() { + try { + const cp = require('child_process'); + return { blocked: false }; + } catch(e) { + return { blocked: true }; + } + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.blocked).toBe(true); + }); + + it('无法通过 Bun.spawn 创建子进程', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `async function main() { + const bunAvailable = typeof Bun !== 'undefined'; + return { bunAvailable }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.bunAvailable).toBe(false); + }); + + it('process.send / process.disconnect 不可用(IPC 隔离)', async () => { + pool = new ProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `async function main() { + return { + hasSend: typeof process.send === 'function', + hasDisconnect: typeof process.disconnect === 'function' + }; + }`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.hasSend).toBe(false); + expect(result.data?.codeReturn.hasDisconnect).toBe(false); + }); + }); + + describe('Python 进程干扰防护', () => { + let pool: PythonProcessPool; + + afterEach(async () => { + try { + await pool?.shutdown(); + } catch {} + }); + + it('无法 import subprocess', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `def main():\n try:\n import subprocess\n return {'blocked': False}\n except Exception:\n return {'blocked': True}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.blocked).toBe(true); + }); + + it('无法 import multiprocessing', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `def main():\n try:\n import multiprocessing\n return {'blocked': False}\n except Exception:\n return {'blocked': True}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.blocked).toBe(true); + }); + + it('无法 import signal 发送信号', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `def main():\n try:\n import signal\n return {'blocked': False}\n except Exception:\n return {'blocked': True}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.blocked).toBe(true); + }); + + it('无法 import threading 创建线程', async () => { + pool = new PythonProcessPool(1); + await pool.init(); + + const result = await pool.execute({ + code: `def main():\n try:\n import threading\n return {'blocked': False}\n except Exception:\n return {'blocked': True}`, + variables: {} + }); + expect(result.success).toBe(true); + expect(result.data?.codeReturn.blocked).toBe(true); + }); + }); +}); diff --git a/projects/sandbox/test/unit/semaphore.test.ts b/projects/sandbox/test/unit/semaphore.test.ts new file mode 100644 index 0000000000..3d8b576f49 --- /dev/null +++ b/projects/sandbox/test/unit/semaphore.test.ts @@ -0,0 +1,334 @@ +/** + * Semaphore 信号量单元测试 + * + * 测试并发控制核心逻辑: + * - 基本 acquire/release 流程 + * - 超出 max 后排队等待 + * - release 唤醒队列中下一个 + * - stats 返回正确的 current/queued/max + * - 并发数为 1 时串行执行 + * - 大量并发请求排队后依次完成 + */ +import { describe, it, expect } from 'vitest'; +import { Semaphore } from '../../src/utils/semaphore'; + +describe('Semaphore', () => { + // ===== 基本流程 ===== + + it('acquire 在未满时立即返回', async () => { + const sem = new Semaphore(3); + // 三次 acquire 都应该立即 resolve + await sem.acquire(); + await sem.acquire(); + await sem.acquire(); + expect(sem.stats).toEqual({ current: 3, queued: 0, max: 3 }); + }); + + it('release 减少 current 计数', async () => { + const sem = new Semaphore(2); + await sem.acquire(); + await sem.acquire(); + expect(sem.stats.current).toBe(2); + sem.release(); + expect(sem.stats.current).toBe(1); + sem.release(); + expect(sem.stats.current).toBe(0); + }); + + it('stats 返回正确的 current/queued/max', async () => { + const sem = new Semaphore(2); + expect(sem.stats).toEqual({ current: 0, queued: 0, max: 2 }); + + await sem.acquire(); + expect(sem.stats).toEqual({ current: 1, queued: 0, max: 2 }); + + await sem.acquire(); + expect(sem.stats).toEqual({ current: 2, queued: 0, max: 2 }); + + // 第三个会排队(不 await,因为它不会 resolve) + const p3 = sem.acquire(); + expect(sem.stats).toEqual({ current: 2, queued: 1, max: 2 }); + + // 第四个也排队 + const p4 = sem.acquire(); + expect(sem.stats).toEqual({ current: 2, queued: 2, max: 2 }); + + // release 唤醒队列中第一个,queued 减 1,current 不变(因为立即被新的占用) + sem.release(); + await p3; + expect(sem.stats).toEqual({ current: 2, queued: 1, max: 2 }); + + sem.release(); + await p4; + expect(sem.stats).toEqual({ current: 2, queued: 0, max: 2 }); + }); + + // ===== 排队与唤醒 ===== + + it('超出 max 后排队等待,release 唤醒下一个', async () => { + const sem = new Semaphore(1); + const order: number[] = []; + + await sem.acquire(); + order.push(1); + + // 第二个 acquire 会排队 + const p2 = sem.acquire().then(() => { + order.push(2); + }); + expect(sem.stats.queued).toBe(1); + + // release 唤醒排队的 + sem.release(); + await p2; + expect(order).toEqual([1, 2]); + expect(sem.stats.queued).toBe(0); + + sem.release(); + expect(sem.stats.current).toBe(0); + }); + + it('release 按 FIFO 顺序唤醒', async () => { + const sem = new Semaphore(1); + const order: number[] = []; + + await sem.acquire(); + + const p1 = sem.acquire().then(() => { + order.push(1); + }); + const p2 = sem.acquire().then(() => { + order.push(2); + }); + const p3 = sem.acquire().then(() => { + order.push(3); + }); + + expect(sem.stats.queued).toBe(3); + + // 依次 release,应按 FIFO 顺序唤醒 + sem.release(); + await p1; + + sem.release(); + await p2; + + sem.release(); + await p3; + + expect(order).toEqual([1, 2, 3]); + }); + + // ===== 并发数为 1 时串行执行 ===== + + it('max=1 时保证串行执行', async () => { + const sem = new Semaphore(1); + const log: string[] = []; + + const task = async (name: string, delayMs: number) => { + await sem.acquire(); + log.push(`${name}-start`); + await new Promise((r) => setTimeout(r, delayMs)); + log.push(`${name}-end`); + sem.release(); + }; + + // 同时启动三个任务 + await Promise.all([task('A', 50), task('B', 50), task('C', 50)]); + + // 串行执行:每个任务的 start 必须在前一个 end 之后 + // A-start, A-end, B-start, B-end, C-start, C-end + for (let i = 0; i < log.length - 1; i += 2) { + const startIdx = i; + const endIdx = i + 1; + expect(log[endIdx]).toContain('-end'); + expect(log[startIdx]).toContain('-start'); + // end 在 start 之后 + expect(endIdx).toBeGreaterThan(startIdx); + } + + // 更严格:不能有两个 start 连续出现(说明并行了) + for (let i = 0; i < log.length - 1; i++) { + if (log[i].endsWith('-start')) { + expect(log[i + 1]).toContain('-end'); + } + } + }); + + // ===== 大量并发 ===== + + it('大量并发请求排队后依次完成', async () => { + const sem = new Semaphore(3); + const total = 20; + let completed = 0; + let maxConcurrent = 0; + let currentRunning = 0; + + const tasks = Array.from({ length: total }, (_, i) => + (async () => { + await sem.acquire(); + currentRunning++; + if (currentRunning > maxConcurrent) { + maxConcurrent = currentRunning; + } + // 模拟异步工作 + await new Promise((r) => setTimeout(r, 10)); + currentRunning--; + completed++; + sem.release(); + })() + ); + + await Promise.all(tasks); + + // 全部完成 + expect(completed).toBe(total); + // 最大并发不超过 max + expect(maxConcurrent).toBeLessThanOrEqual(3); + // 最终状态归零 + expect(sem.stats.current).toBe(0); + expect(sem.stats.queued).toBe(0); + }); + + it('max=1 大量并发严格串行', async () => { + const sem = new Semaphore(1); + const total = 10; + let maxConcurrent = 0; + let currentRunning = 0; + + const tasks = Array.from({ length: total }, () => + (async () => { + await sem.acquire(); + currentRunning++; + if (currentRunning > maxConcurrent) maxConcurrent = currentRunning; + await new Promise((r) => setTimeout(r, 5)); + currentRunning--; + sem.release(); + })() + ); + + await Promise.all(tasks); + expect(maxConcurrent).toBe(1); + expect(sem.stats.current).toBe(0); + }); + + // ===== 边界情况 ===== + + it('release 无排队时 current 不会变为负数', () => { + const sem = new Semaphore(3); + // 没有 acquire 就 release + sem.release(); + // current 变为 -1,这是实现的已知行为(调用者应保证配对使用) + expect(sem.stats.current).toBe(-1); + }); + + it('max 为很大的数时不排队', async () => { + const sem = new Semaphore(1000); + const promises = Array.from({ length: 100 }, () => sem.acquire()); + await Promise.all(promises); + expect(sem.stats.current).toBe(100); + expect(sem.stats.queued).toBe(0); + }); + + it('acquire 返回的 Promise 是 void', async () => { + const sem = new Semaphore(1); + const result = await sem.acquire(); + expect(result).toBeUndefined(); + sem.release(); + }); +}); + +// ============================================================ +// 竞态条件补充(原 semaphore-race.test.ts) +// ============================================================ +describe('Semaphore 竞态条件补充', () => { + it('release 过多后 acquire 仍能正常工作', async () => { + const sem = new Semaphore(2); + sem.release(); + expect(sem.stats.current).toBe(-1); + + await sem.acquire(); + expect(sem.stats.current).toBe(0); + await sem.acquire(); + expect(sem.stats.current).toBe(1); + await sem.acquire(); + expect(sem.stats.current).toBe(2); + + const p = sem.acquire(); + expect(sem.stats.queued).toBe(1); + sem.release(); + await p; + }); + + it('快速交替 acquire/release 不丢失状态', async () => { + const sem = new Semaphore(1); + for (let i = 0; i < 100; i++) { + await sem.acquire(); + sem.release(); + } + expect(sem.stats.current).toBe(0); + expect(sem.stats.queued).toBe(0); + }); + + it('异步任务异常后 release 仍被调用(模拟 try/finally)', async () => { + const sem = new Semaphore(2); + const errors: string[] = []; + + const task = async (shouldFail: boolean) => { + await sem.acquire(); + try { + if (shouldFail) throw new Error('task failed'); + return 'ok'; + } catch (e: any) { + errors.push(e.message); + return 'error'; + } finally { + sem.release(); + } + }; + + const results = await Promise.all([ + task(false), + task(true), + task(false), + task(true), + task(false) + ]); + + expect(results.filter((r) => r === 'ok')).toHaveLength(3); + expect(results.filter((r) => r === 'error')).toHaveLength(2); + expect(errors).toHaveLength(2); + expect(sem.stats.current).toBe(0); + expect(sem.stats.queued).toBe(0); + }); + + it('max=0 时所有 acquire 都排队', async () => { + const sem = new Semaphore(0); + const p1 = sem.acquire(); + const p2 = sem.acquire(); + expect(sem.stats.queued).toBe(2); + expect(sem.stats.current).toBe(0); + + sem.release(); + await p1; + sem.release(); + await p2; + }); + + it('并发 acquire 后批量 release', async () => { + const sem = new Semaphore(2); + await sem.acquire(); + await sem.acquire(); + + const waiters = Array.from({ length: 5 }, () => sem.acquire()); + expect(sem.stats.queued).toBe(5); + + for (let i = 0; i < 7; i++) { + sem.release(); + } + await Promise.all(waiters); + + expect(sem.stats.queued).toBe(0); + expect(sem.stats.current).toBe(0); + }); +}); diff --git a/projects/sandbox/testSystemCall.sh b/projects/sandbox/testSystemCall.sh deleted file mode 100644 index c8aa83b08f..0000000000 --- a/projects/sandbox/testSystemCall.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/bash - -temp_dir=$(mktemp -d) -trap 'rm -rf "$temp_dir"' EXIT - -syscall_table_file="$temp_dir/syscall_table.txt" -code_file="$temp_dir/test_code.py" -strace_log="$temp_dir/strace.log" -syscalls_file="$temp_dir/syscalls.txt" - -code=' -import pandas as pd -def main(): - data = {"Name": ["Alice", "Bob"], "Age": [25, 30]} - df = pd.DataFrame(data) - return { - "head": df.head().to_dict() - } -' - -if ! ausyscall --dump > "$syscall_table_file" 2>/dev/null; then - grep -E '^#define __NR_' /usr/include/asm/unistd_64.h | \ - sed 's/#define __NR_//;s/[ \t]\+/ /g' | \ - awk '{print $1, $2}' > "$syscall_table_file" -fi - -echo "$code" > "$code_file" - -strace -ff -e trace=all -o "$strace_log" python3 "$code_file" >/dev/null 2>&1 - -cat "$strace_log"* 2>/dev/null | grep -oE '^[[:alnum:]_]+' | sort -u > "$syscalls_file" - -allowed_syscalls=() -while read raw_name; do - go_name=$(echo "$raw_name" | tr 'a-z' 'A-Z' | sed 's/-/_/g') - allowed_syscalls+=("\"syscall.SYS_${go_name}\"") -done < "$syscalls_file" - -echo "allowed_syscalls = [" -printf ' %s,\n' "${allowed_syscalls[@]}" | paste -sd ' \n' -echo "]" \ No newline at end of file diff --git a/projects/sandbox/tsconfig.build.json b/projects/sandbox/tsconfig.build.json deleted file mode 100644 index 64f86c6bd2..0000000000 --- a/projects/sandbox/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] -} diff --git a/projects/sandbox/tsconfig.json b/projects/sandbox/tsconfig.json index 95f5641cf7..76b614f2b5 100644 --- a/projects/sandbox/tsconfig.json +++ b/projects/sandbox/tsconfig.json @@ -1,21 +1,17 @@ { "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2021", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, "skipLibCheck": true, - "strictNullChecks": false, - "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false - } + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "resolveJsonModule": true, + "types": ["bun-types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] } diff --git a/projects/sandbox/vitest.config.ts b/projects/sandbox/vitest.config.ts new file mode 100644 index 0000000000..8b19fc1c19 --- /dev/null +++ b/projects/sandbox/vitest.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + enabled: true, + reporter: ['text', 'text-summary', 'html', 'json-summary', 'json'], + reportOnFailure: true, + all: false, // 只包含被测试实际覆盖的文件,不包含空目录 + include: ['src/**/*.ts'], + cleanOnRerun: false + }, + root: '.', + include: ['test/**/*.test.ts'], + testTimeout: 30000, + hookTimeout: 30000, + fileParallelism: false, + maxConcurrency: 1, + isolate: false, + env: { + SANDBOX_MAX_TIMEOUT: '5000', + SANDBOX_TOKEN: 'test' + } + } +}); diff --git a/vitest.config.mts b/vitest.config.mts index 045c4da0cc..4450c40e5b 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -15,7 +15,7 @@ export default defineConfig({ reporter: ['text', 'text-summary', 'html', 'json-summary', 'json'], reportOnFailure: true, all: false, // 只包含被测试实际覆盖的文件,不包含空目录 - include: ['projects/**/*.ts', 'packages/**/*.ts'], + include: ['projects/app/**/*.ts', 'packages/**/*.ts'], exclude: [ '**/node_modules/**', '**/*.spec.ts', @@ -48,7 +48,6 @@ export default defineConfig({ include: [ 'test/**/*.test.ts', 'projects/app/test/**/*.test.ts', - 'projects/sandbox/test/**/*.test.ts', 'projects/marketplace/test/**/*.test.ts' ] }