Commit 44d64ce4 by Archer Committed by GitHub

Sandbox server (#6383)

* feat: sandbox_server

* docker build

* action
parent 214b3138
name: Build Sandbox Server Image
on:
workflow_dispatch:
inputs:
tag:
description: 'Image tag (e.g., v1.0.0)'
required: true
type: string
jobs:
build-sandbox-server-images:
permissions:
packages: write
contents: read
attestations: write
id-token: write
strategy:
matrix:
archs:
- arch: amd64
- arch: arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.archs.runs-on || 'ubuntu-24.04' }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Cache Docker layers
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-${{ matrix.archs.arch }}-sandbox-server-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-${{ matrix.archs.arch }}-sandbox-server-buildx-
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Ali Hub
uses: docker/login-action@v3
with:
registry: registry.cn-hangzhou.aliyuncs.com
username: ${{ secrets.ALI_HUB_USERNAME }}
password: ${{ secrets.ALI_HUB_PASSWORD }}
- name: Build for ${{ matrix.archs.arch }}
id: build
uses: docker/build-push-action@v6
with:
context: ./projects/sandbox_server
file: ./projects/sandbox_server/Dockerfile
platforms: linux/${{ matrix.archs.arch }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.description=FastGPT Sandbox Server image
outputs: type=image,"name=ghcr.io/${{ github.repository_owner }}/fastgpt-sandbox-server,${{ secrets.ALI_IMAGE_NAME }}/fastgpt-sandbox-server",push-by-digest=true,push=true
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests/fastgpt-sandbox-server
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/fastgpt-sandbox-server/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-fastgpt-sandbox-server-${{ github.sha }}-${{ matrix.archs.arch }}
path: ${{ runner.temp }}/digests/fastgpt-sandbox-server/*
if-no-files-found: error
retention-days: 1
release-sandbox-server-images:
permissions:
packages: write
contents: read
attestations: write
id-token: write
needs: build-sandbox-server-images
runs-on: ubuntu-24.04
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Ali Hub
uses: docker/login-action@v3
with:
registry: registry.cn-hangzhou.aliyuncs.com
username: ${{ secrets.ALI_HUB_USERNAME }}
password: ${{ secrets.ALI_HUB_PASSWORD }}
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-fastgpt-sandbox-server-${{ github.sha }}-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
TAGS=(
"ghcr.io/${{ github.repository_owner }}/fastgpt-sandbox-server:${{ inputs.tag }}"
"ghcr.io/${{ github.repository_owner }}/fastgpt-sandbox-server:latest"
"${{ secrets.ALI_IMAGE_NAME }}/fastgpt-sandbox-server:${{ inputs.tag }}"
"${{ secrets.ALI_IMAGE_NAME }}/fastgpt-sandbox-server:latest"
)
for TAG in "${TAGS[@]}"; do
docker buildx imagetools create -t $TAG \
$(printf 'ghcr.io/${{ github.repository_owner }}/fastgpt-sandbox-server@sha256:%s ' *)
sleep 5
done
...@@ -6,6 +6,7 @@ description: 'FastGPT V4.14.7 更新说明' ...@@ -6,6 +6,7 @@ description: 'FastGPT V4.14.7 更新说明'
## 🚀 新增内容 ## 🚀 新增内容
1. 知识库搜索,支持指定 collectionIds 来进行筛选。
## ⚙️ 优化 ## ⚙️ 优化
......
...@@ -120,11 +120,11 @@ ...@@ -120,11 +120,11 @@
"document/content/docs/upgrading/4-14/4140.mdx": "2025-11-06T15:43:00+08:00", "document/content/docs/upgrading/4-14/4140.mdx": "2025-11-06T15:43:00+08:00",
"document/content/docs/upgrading/4-14/4141.mdx": "2025-12-31T09:54:29+08:00", "document/content/docs/upgrading/4-14/4141.mdx": "2025-12-31T09:54:29+08:00",
"document/content/docs/upgrading/4-14/4142.mdx": "2025-11-18T19:27:14+08:00", "document/content/docs/upgrading/4-14/4142.mdx": "2025-11-18T19:27:14+08:00",
"document/content/docs/upgrading/4-14/4143.mdx": "2025-11-26T20:52:05+08:00", "document/content/docs/upgrading/4-14/4143.mdx": "2026-02-04T14:27:58+08:00",
"document/content/docs/upgrading/4-14/4144.mdx": "2025-12-16T14:56:04+08:00", "document/content/docs/upgrading/4-14/4144.mdx": "2026-02-04T14:27:58+08:00",
"document/content/docs/upgrading/4-14/4145.mdx": "2026-01-18T23:59:15+08:00", "document/content/docs/upgrading/4-14/4145.mdx": "2026-01-18T23:59:15+08:00",
"document/content/docs/upgrading/4-14/41451.mdx": "2026-01-20T11:53:27+08:00", "document/content/docs/upgrading/4-14/41451.mdx": "2026-01-20T11:53:27+08:00",
"document/content/docs/upgrading/4-14/4146.mdx": "2026-02-04T14:20:54+08:00", "document/content/docs/upgrading/4-14/4146.mdx": "2026-02-04T14:27:58+08:00",
"document/content/docs/upgrading/4-14/4147.mdx": "2026-02-02T18:48:25+08:00", "document/content/docs/upgrading/4-14/4147.mdx": "2026-02-02T18:48:25+08:00",
"document/content/docs/upgrading/4-8/40.mdx": "2025-08-02T19:38:37+08:00", "document/content/docs/upgrading/4-8/40.mdx": "2025-08-02T19:38:37+08:00",
"document/content/docs/upgrading/4-8/41.mdx": "2025-08-02T19:38:37+08:00", "document/content/docs/upgrading/4-8/41.mdx": "2025-08-02T19:38:37+08:00",
......
packages: packages:
- packages/* - packages/*
- projects/* - projects/app
- projects/marketplace
- projects/mcp_server
- projects/sandbox
- scripts/icon - scripts/icon
- sdk/* - sdk/*
# Dependencies
node_modules
# Git
.git
.gitignore
# IDE
.vscode
.idea
# Test files
test
*.test.ts
vitest.config.ts
# Environment files
.env
.env.local
.env.*.local
# Build artifacts
dist
*.log
# Documentation
*.md
!README.md
# Server Configuration
PORT=3000
# API Authentication Token
TOKEN=your-secret-token
# Sealos Configuration
SEALOS_BASE_URL=https://applaunchpad.hzh.sealos.run
SEALOS_KC=
# Container Configuration (fixed for all containers)
CONTAINER_IMAGE=hub.hzh.sealos.run/ns-4gabgrbc/agent-sandbox:v0.0.7
CONTAINER_PORT=8080
CONTAINER_CPU=0.5
CONTAINER_MEMORY=1
# Entrypoint format: JSON array like '["/bin/bash","-c","script.sh"]' or plain command
CONTAINER_ENTRYPOINT='["/bin/bash -c","/home/devbox/project/entrypoint.sh prod"]'
# Whether to expose container to public domain
CONTAINER_EXPOSES_PUBLIC_DOMAIN=true
\ No newline at end of file
# Dependencies
node_modules
# Environment files
.env
.env.local
.env.*.local
.env.test
# Build
dist
*.tsbuildinfo
# Logs
*.log
npm-debug.log*
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Test coverage
coverage
# Bun
bun.lockb
# ==================== Base ====================
FROM oven/bun:1 AS base
WORKDIR /app
# ==================== Install All Dependencies ====================
FROM base AS deps
# Copy package files
COPY package.json bun.lock* ./
# Install all dependencies (including devDependencies for build)
RUN bun install --frozen-lockfile
# ==================== Build ====================
FROM deps AS build
# Copy source code and config
COPY src ./src
COPY tsconfig.json ./
# Build the application
RUN bun build src/index.ts --outdir=dist --target=bun --minify
# ==================== Production Dependencies ====================
FROM base AS prod-deps
COPY package.json bun.lock* ./
# Install production dependencies only
RUN bun install --frozen-lockfile --production
# ==================== Release ====================
FROM oven/bun:1-slim AS release
WORKDIR /app
# Copy production dependencies
COPY --from=prod-deps /app/node_modules ./node_modules
# Copy built application
COPY --from=build /app/dist ./dist
# Copy package.json for metadata
COPY package.json ./
# Create non-root user for security
RUN groupadd --system --gid 1001 nodejs && \
useradd --system --uid 1001 --gid nodejs --no-create-home hono && \
chown -R hono:nodejs /app
USER hono
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3000
# Expose port
EXPOSE 3000
# Health check using bun fetch (no curl needed in slim image)
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
# Start the application
CMD ["bun", "run", "dist/index.js"]
# 快速开始指南
## ✅ 项目已完成
所有功能已实现并通过测试。
## 📁 项目结构
```
sandbox_server/
├── src/
│ ├── index.ts # 应用入口
│ ├── env.ts # 环境变量配置
│ ├── schemas/ # Zod Schema 定义(类型导出)
│ │ ├── common.schema.ts # 公共 schema
│ │ ├── container.schema.ts # 容器 schema
│ │ └── sandbox.schema.ts # 沙盒 schema
│ ├── middleware/ # 中间件
│ │ ├── auth.ts # Bearer token 鉴权
│ │ └── error.ts # 统一错误处理
│ ├── clients/ # 客户端(axios 实例)
│ │ ├── sealos.ts # Sealos API 客户端
│ │ └── sandbox.ts # Sandbox 客户端
│ ├── routes/ # 路由(OpenAPI 定义)
│ │ ├── container.route.ts # 容器生命周期路由
│ │ └── sandbox.route.ts # 沙盒操作路由
│ └── sdk/ # SDK 模块
│ ├── container.ts # sdk.container.*
│ └── sandbox.ts # sdk.sandbox.*
├── test/ # 测试
│ ├── setup.ts # 测试配置
│ ├── .env.test.template # 测试环境变量模板
│ └── app.test.ts # 基础测试
├── Dockerfile # Docker 构建
├── .env.template # 环境变量模板
└── package.json
```
## 🚀 启动步骤
### 1. 安装依赖
```bash
cd FastGPT/projects/sandbox_server
bun install
```
### 2. 配置环境变量
复制 `.env.template``.env.local` 并填写配置:
```bash
cp .env.template .env.local
```
编辑 `.env.local`
```env
PORT=3000
TOKEN=your-secret-token
SEALOS_BASE_URL=https://your-sealos-api-url.com
SEALOS_KC=your-kubeconfig-token
```
### 3. 启动开发服务器
```bash
bun run dev
```
### 4. 访问 API 文档
- **Scalar UI**: http://localhost:3000/openapi/ui
- **OpenAPI JSON**: http://localhost:3000/openapi
- **健康检查**: http://localhost:3000/health
## 📝 API 端点
### 容器生命周期 (`/v1/containers`)
| 方法 | 路径 | 描述 | 鉴权 |
|------|------|------|------|
| POST | `/v1/containers` | 创建容器 | ✅ |
| GET | `/v1/containers/:name` | 获取容器信息 | ✅ |
| POST | `/v1/containers/:name/pause` | 暂停容器 | ✅ |
| POST | `/v1/containers/:name/start` | 启动容器 | ✅ |
| DELETE | `/v1/containers/:name` | 删除容器 | ✅ |
### 沙盒操作 (`/v1/sandbox`)
| 方法 | 路径 | 描述 | 鉴权 |
|------|------|------|------|
| POST | `/v1/sandbox/:name/exec` | 执行命令 | ✅ |
| GET | `/v1/sandbox/:name/health` | 健康检查 | ✅ |
## 💡 SDK 使用示例
```typescript
import { createSDK } from './sdk';
const sdk = createSDK('http://localhost:3000', 'your-token');
// 创建容器
await sdk.container.create({
name: 'my-sandbox',
image: 'node:18-alpine',
resource: { cpu: 1, memory: 1024 }
});
// 获取容器信息
const info = await sdk.container.get('my-sandbox');
// 暂停容器
await sdk.container.pause('my-sandbox');
// 启动容器
await sdk.container.start('my-sandbox');
// 执行命令
const result = await sdk.sandbox.exec('my-sandbox', {
command: 'ls -la',
cwd: '/app'
});
console.log(result.stdout);
// 健康检查
const healthy = await sdk.sandbox.health('my-sandbox');
// 删除容器
await sdk.container.delete('my-sandbox');
```
## 🧪 测试
### 单元测试
```bash
# 运行所有测试
bun run test
# 运行单次测试
bun run test:run
# 类型检查
bun run typecheck
```
### 集成测试
集成测试需要真实的 Sealos 环境。
1. 配置测试环境变量:
```bash
cp test/.env.test.template test/.env.test.local
# 编辑 test/.env.test.local,填写真实配置
```
2. 运行集成测试:
```bash
RUN_INTEGRATION_TESTS=true bun run test
```
详细说明请查看 [`test/README.md`](test/README.md)
## 🐳 Docker 部署
```bash
# 构建镜像
docker build -t sandbox-server .
# 运行容器
docker run -p 3000:3000 --env-file .env.local sandbox-server
```
## ✨ 特性
-**Bun 运行时**: 快速的包管理和执行
-**Hono 框架**: 轻量级高性能 HTTP 框架
-**Zod 类型验证**: 所有入参出参均使用 zod parse
-**OpenAPI 文档**: 自动生成 API 文档(使用 @hono/zod-openapi)
-**Scalar UI**: 现代化 API 文档界面
-**类型安全 SDK**: 提供完整的 TypeScript 类型支持
-**Bearer Token 鉴权**: 统一的认证中间件
-**统一错误处理**: 避免 API 报错时未响应
-**工厂模式**: 优雅的控制器设计
-**Axios 客户端**: 为不同场景定制的 axios 实例
-**Vitest 测试**: 单元测试和集成测试支持
## 📦 核心依赖
- `hono` - HTTP 框架
- `@hono/zod-openapi` - OpenAPI 集成
- `@scalar/hono-api-reference` - API 文档 UI
- `@t3-oss/env-core` - 环境变量管理
- `axios` - HTTP 客户端
- `zod` - Schema 验证
- `vitest` - 测试框架
## 🔧 环境变量
| 变量 | 必填 | 描述 | 默认值 |
|------|------|------|--------|
| `PORT` | ❌ | 服务器端口 | 3000 |
| `TOKEN` | ✅ | API 认证 token | - |
| `SEALOS_BASE_URL` | ✅ | Sealos API 地址 | - |
| `SEALOS_KC` | ✅ | Sealos Kubeconfig | - |
## 📞 问题排查
### 1. 依赖安装失败
```bash
rm -rf node_modules bun.lockb
bun install
```
### 2. 类型错误
```bash
bun run typecheck
```
### 3. 测试失败
确保测试环境变量已正确设置(见 `test/setup.ts`
---
🎉 **项目已完成!所有功能均已实现并通过测试。**
# FastGPT Sandbox Server
借助 Sealos 的部署能力,进行快速的沙盒管理以及使用。
## 功能
- **容器生命周期管理**: 创建、暂停、启动、删除容器
- **沙盒操作**: 在沙盒中执行命令、健康检查
- **OpenAPI 文档**: 自动生成 API 文档
- **SDK**: 提供类型安全的 SDK 调用
## 快速开始
### 安装依赖
```bash
bun install
```
### 配置环境变量
复制 `.env.template``.env.local` 并填写配置:
```bash
cp .env.template .env.local
```
### 启动开发服务器
```bash
bun run dev
```
### 运行测试
```bash
bun run test
```
## API 文档
启动服务后访问:
- OpenAPI JSON: `http://localhost:3000/openapi`
- Scalar UI: `http://localhost:3000/openapi/ui`
## API 端点
### 容器生命周期 (`/v1/containers`)
| 方法 | 路径 | 描述 |
|------|------|------|
| POST | `/v1/containers` | 创建容器 |
| GET | `/v1/containers/:name` | 获取容器信息 |
| POST | `/v1/containers/:name/pause` | 暂停容器 |
| POST | `/v1/containers/:name/start` | 启动容器 |
| DELETE | `/v1/containers/:name` | 删除容器 |
### 沙盒操作 (`/v1/sandbox`)
| 方法 | 路径 | 描述 |
|------|------|------|
| POST | `/v1/sandbox/:name/exec` | 执行命令 |
| GET | `/v1/sandbox/:name/health` | 健康检查 |
## SDK 使用
```typescript
import { createSDK } from 'sandbox-server/sdk';
const sdk = createSDK('http://localhost:3000', 'your-token');
// 容器操作
await sdk.container.create({
name: 'my-sandbox',
image: 'node:18-alpine',
resource: { cpu: 1, memory: 1024 }
});
const info = await sdk.container.get('my-sandbox');
await sdk.container.pause('my-sandbox');
await sdk.container.start('my-sandbox');
await sdk.container.delete('my-sandbox');
// 沙盒操作
const healthy = await sdk.sandbox.health('my-sandbox');
const result = await sdk.sandbox.exec('my-sandbox', { command: 'ls -la' });
```
## Docker 构建
```bash
docker build -t sandbox-server .
docker run -p 3000:3000 --env-file .env.local sandbox-server
```
{
"name": "sandbox-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"build": "tsc --noEmit && bun build src/index.ts --outdir=dist --target=bun",
"start": "bun run src/index.ts",
"start:prod": "bun run dist/index.js",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit"
},
"exports": {
".": "./src/index.ts",
"./sdk": "./src/sdk/index.ts"
},
"dependencies": {
"@hono/zod-openapi": "^1.2.1",
"@scalar/hono-api-reference": "^0.9.40",
"@t3-oss/env-core": "^0.13.10",
"axios": "^1.7.0",
"hono": "^4.11.7",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/bun": "latest",
"@types/nock": "^11.1.0",
"@vitest/coverage-v8": "^3.0.9",
"nock": "^14.0.10",
"typescript": "^5.0.0",
"vitest": "^3.0.0"
}
}
dist
node_modules
*.log
.DS_Store
# SDK 构建说明
## 构建步骤
### 1. 安装依赖
```bash
cd /Volumes/code/fastgpt-pro/FastGPT/projects/sandbox_server/sdk
pnpm install
```
### 2. 构建 SDK
```bash
pnpm run build
```
这将生成以下文件到 `dist` 目录:
- `index.js` - ESM 格式
- `index.cjs` - CommonJS 格式
- `index.d.ts` - TypeScript 类型定义
- 对应的 sourcemap 文件
### 3. 开发模式
在开发过程中,可以使用 watch 模式自动重新构建:
```bash
pnpm run dev
```
### 4. 类型检查
```bash
pnpm run typecheck
```
## 发布到 npm
### 1. 登录 npm
```bash
npm login
```
### 2. 发布
```bash
pnpm publish
```
发布前会自动运行 `prepublishOnly` 脚本进行构建。
## 本地测试
### 1. 创建本地链接
```bash
cd /Volumes/code/fastgpt-pro/FastGPT/projects/sandbox_server/sdk
pnpm link --global
```
### 2. 在其他项目中使用
```bash
cd /path/to/your/project
pnpm link --global @fastgpt-sdk/sandbox_server
```
### 3. 测试完成后取消链接
```bash
pnpm unlink --global @fastgpt-sdk/sandbox_server
```
## 目录结构
```
sdk/
├── dist/ # 构建输出目录
├── index.ts # SDK 入口文件
├── container.ts # 容器管理 SDK
├── sandbox.ts # 沙盒执行 SDK
├── package.json # 包配置
├── tsconfig.json # TypeScript 配置
├── tsup.config.ts # 构建配置
├── README.md # 使用文档
├── BUILD.md # 构建文档
└── .gitignore # Git 忽略文件
```
## 配置说明
### package.json
- `name`: @fastgpt-sdk/sandbox_server
- `main`: CommonJS 入口
- `module`: ESM 入口
- `types`: TypeScript 类型定义入口
- `exports`: 导出配置,支持多种模块格式
### tsup.config.ts
- `entry`: 入口文件
- `format`: 输出格式 (esm, cjs)
- `dts`: 生成 TypeScript 类型定义
- `clean`: 构建前清理输出目录
- `sourcemap`: 生成 sourcemap
- `external`: 外部依赖(不打包到 bundle 中)
## 依赖说明
### dependencies
- `axios`: HTTP 客户端
- `zod`: 运行时类型验证
### devDependencies
- `tsup`: TypeScript 打包工具
- `typescript`: TypeScript 编译器
- `@types/node`: Node.js 类型定义
### peerDependencies
确保使用 SDK 的项目也安装了相同版本的 axios 和 zod。
import axios, { type AxiosInstance } from 'axios';
import {
CreateContainerSchema,
ContainerInfoResponseSchema,
SuccessResponseSchema
} from './schemas';
import type { CreateContainerInput, ContainerInfo } from './types';
/**
* Container SDK
* Provides type-safe API calls for container lifecycle management
*/
export class ContainerSDK {
private readonly client: AxiosInstance;
constructor(baseUrl: string, token: string) {
this.client = axios.create({
baseURL: `${baseUrl.replace(/\/$/, '')}/v1`,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
});
}
/**
* Create a new container
*/
async create(params: CreateContainerInput): Promise<void> {
const validated = CreateContainerSchema.parse(params);
const response = await this.client.post('/containers', validated);
SuccessResponseSchema.parse(response.data);
}
/**
* Get container information
*/
async get(name: string): Promise<ContainerInfo | null> {
try {
const response = await this.client.get(`/containers/${encodeURIComponent(name)}`);
const result = ContainerInfoResponseSchema.parse(response.data);
return result.data;
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 404) {
return null;
}
throw err;
}
}
/**
* Pause a running container
*/
async pause(name: string): Promise<void> {
const response = await this.client.post(`/containers/${encodeURIComponent(name)}/pause`);
SuccessResponseSchema.parse(response.data);
}
/**
* Start a paused container
*/
async start(name: string): Promise<void> {
const response = await this.client.post(`/containers/${encodeURIComponent(name)}/start`);
SuccessResponseSchema.parse(response.data);
}
/**
* Delete a container
*/
async delete(name: string): Promise<void> {
const response = await this.client.delete(`/containers/${encodeURIComponent(name)}`);
SuccessResponseSchema.parse(response.data);
}
}
import { ContainerSDK } from './container';
import { SandboxSDK } from './sandbox';
export { ContainerSDK } from './container';
export { SandboxSDK } from './sandbox';
// Export types (independent definitions, no external dependencies)
export type {
CreateContainerInput,
ContainerInfo,
ContainerStatus,
ContainerServer,
ExecRequest,
ExecResponse
} from './types';
/**
* SDK Configuration
*/
export interface SDKConfig {
baseUrl: string;
token: string;
}
/**
* Sandbox Server SDK
* Provides type-safe API calls for all sandbox server operations
*
* @example
* ```typescript
* import { createSDK } from 'sandbox-server/sdk';
*
* const sdk = createSDK('http://localhost:3000', 'your-token');
*
* // Container lifecycle
* await sdk.container.create({ name: 'test', image: 'node:18', resource: { cpu: 1, memory: 1024 } });
* const info = await sdk.container.get('test');
* await sdk.container.pause('test');
* await sdk.container.start('test');
* await sdk.container.delete('test');
*
* // Sandbox operations
* const healthy = await sdk.sandbox.health('test');
* const result = await sdk.sandbox.exec('test', { command: 'ls -la' });
* ```
*/
export interface SandboxServerSDK {
container: ContainerSDK;
sandbox: SandboxSDK;
}
/**
* Create a new SDK instance
*/
export function createSDK(baseUrl: string, token: string): SandboxServerSDK {
return {
container: new ContainerSDK(baseUrl, token),
sandbox: new SandboxSDK(baseUrl, token)
};
}
/**
* Create a new SDK instance from config
*/
export function createSDKFromConfig(config: SDKConfig): SandboxServerSDK {
return createSDK(config.baseUrl, config.token);
}
{
"name": "@fastgpt-sdk/sandbox-server",
"version": "0.0.5",
"description": "Type-safe SDK for FastGPT Sandbox Server API",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"clean": "rm -rf dist",
"build:js": "bun build index.ts --outdir dist --target node",
"build:dts": "tsc --emitDeclarationOnly --outDir dist",
"build": "bun run clean && bun run build:js && bun run build:dts",
"dev": "bun run --watch index.ts",
"typecheck": "tsc --noEmit",
"prepublishOnly": "bun run build"
},
"keywords": [
"fastgpt",
"sandbox",
"sdk",
"typescript"
],
"license": "MIT",
"dependencies": {
"axios": "^1.7.0",
"zod": "^3.22.0"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"axios": "^1.7.0",
"zod": "^3.22.0"
},
"publishConfig": {
"access": "public"
}
}
import axios, { type AxiosInstance } from 'axios';
import { ExecRequestSchema, ExecResultResponseSchema, HealthCheckResponseSchema } from './schemas';
import type { ExecRequest, ExecResponse } from './types';
/**
* Sandbox SDK
* Provides type-safe API calls for sandbox operations
*/
export class SandboxSDK {
private readonly client: AxiosInstance;
constructor(baseUrl: string, token: string) {
this.client = axios.create({
baseURL: `${baseUrl.replace(/\/$/, '')}/v1`,
timeout: 60000,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
});
}
/**
* Execute a command in the sandbox
*/
async exec(name: string, params: ExecRequest): Promise<ExecResponse> {
const validated = ExecRequestSchema.parse(params);
const response = await this.client.post(`/sandbox/${encodeURIComponent(name)}/exec`, validated);
const result = ExecResultResponseSchema.parse(response.data);
return result.data;
}
/**
* Check sandbox health
*/
async health(name: string): Promise<boolean> {
const response = await this.client.get(`/sandbox/${encodeURIComponent(name)}/health`);
const result = HealthCheckResponseSchema.parse(response.data);
return result.healthy;
}
}
/**
* SDK Schemas for runtime validation
* Uses standard zod (not @hono/zod-openapi)
*/
import { z } from 'zod';
// ==================== Common Schemas ====================
export const SuccessResponseSchema = z.object({
success: z.literal(true)
});
// ==================== Container Schemas ====================
export const CreateContainerSchema = z.object({
name: z.string().min(1)
});
const ContainerStatusSchema = z.object({
state: z.enum(['Running', 'Creating', 'Paused', 'Error', 'Unknown']),
replicas: z.number().optional(),
availableReplicas: z.number().optional()
});
const ContainerServerSchema = z.object({
serviceName: z.string(),
number: z.number(),
publicDomain: z.string().optional(),
domain: z.string().optional()
});
const ContainerInfoSchema = z.object({
name: z.string(),
image: z.object({
imageName: z.string()
}),
status: ContainerStatusSchema,
server: ContainerServerSchema.optional(),
createdAt: z.string().optional()
});
export const ContainerInfoResponseSchema = z.object({
success: z.literal(true),
data: ContainerInfoSchema
});
// ==================== Sandbox Schemas ====================
export const ExecRequestSchema = z.object({
command: z.string().min(1),
cwd: z.string().optional()
});
const ExecResponseSchema = z.object({
success: z.boolean(),
stdout: z.string(),
stderr: z.string(),
exitCode: z.number(),
cwd: z.string().optional(),
error: z.string().optional()
});
export const HealthCheckResponseSchema = z.object({
success: z.literal(true),
healthy: z.boolean()
});
export const ExecResultResponseSchema = z.object({
success: z.literal(true),
data: ExecResponseSchema
});
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": ".",
"declaration": true,
"emitDeclarationOnly": true
},
"include": ["./**/*.ts"],
"exclude": ["node_modules", "dist", "example.ts"]
}
/**
* SDK Type Definitions
* Independent type definitions for SDK users (no external dependencies)
*/
// ==================== Container Types ====================
export interface CreateContainerInput {
name: string;
}
export interface ContainerStatus {
state: 'Running' | 'Creating' | 'Paused' | 'Error' | 'Unknown';
replicas?: number;
availableReplicas?: number;
}
export interface ContainerServer {
serviceName: string;
number: number;
publicDomain?: string;
domain?: string;
}
export interface ContainerInfo {
name: string;
image: {
imageName: string;
};
status: ContainerStatus;
server?: ContainerServer;
createdAt?: string;
}
// ==================== Sandbox Types ====================
export interface ExecRequest {
command: string;
cwd?: string;
}
export interface ExecResponse {
success: boolean;
stdout: string;
stderr: string;
exitCode: number;
cwd?: string;
error?: string;
}
export { SealosClient, createSealosClient } from './sealos';
export { SandboxClient, createSandboxClient } from './sandbox';
import axios, { type AxiosInstance, type AxiosError } from 'axios';
import {
ExecRequestSchema,
ExecResponseSchema,
HealthResponseSchema,
type ExecRequest,
type ExecResponse,
type HealthResponse
} from '../schemas';
const DEFAULT_CWD = '/app/sandbox';
/**
* Sandbox Client
* Communicates with the sandbox server running inside container
*/
export class SandboxClient {
private readonly client: AxiosInstance;
private readonly baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 60000, // 60s timeout for long-running commands
headers: {
'Content-Type': 'application/json'
}
});
// Response interceptor for error handling
this.client.interceptors.response.use(
(response) => response,
(error: AxiosError<{ error?: string }>) => {
if (error.code === 'ECONNREFUSED') {
return Promise.reject(new Error('Sandbox server is not reachable'));
}
if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
return Promise.reject(new Error('Request timeout'));
}
const status = error.response?.status;
if (status === 404) {
return Promise.reject(new Error('Endpoint not found'));
}
const responseData = {
status: error.response?.status,
statusText: error.response?.statusText,
message: error.response?.data?.error || error.message || 'Request failed',
data: error.response?.data
};
return Promise.reject(responseData);
}
);
}
/**
* Check if sandbox server is healthy
*/
async health(): Promise<HealthResponse> {
const response = await this.client.get('/health');
return HealthResponseSchema.parse(response.data);
}
/**
* Check if sandbox is healthy (boolean)
*/
async isHealthy(): Promise<boolean> {
try {
const result = await this.health();
return result.status === 'ok';
} catch {
return false;
}
}
/**
* Execute a shell command in the sandbox
*/
async exec(params: ExecRequest): Promise<ExecResponse> {
const validated = ExecRequestSchema.parse(params);
const response = await this.client.post('/exec', {
command: validated.command,
cwd: validated.cwd || DEFAULT_CWD
});
return ExecResponseSchema.parse(response.data);
}
}
/**
* Create a new SandboxClient instance
*/
export function createSandboxClient(baseUrl: string): SandboxClient {
return new SandboxClient(baseUrl);
}
import axios, { type AxiosInstance, type AxiosError } from 'axios';
import {
CreateContainerSchema,
SealosContainerResponseSchema,
type CreateContainerInput,
type ContainerInfo,
type ContainerStatus
} from '../schemas';
import { env, containerConfig } from '../env';
/**
* Sealos API Client
* Handles container lifecycle management through Sealos API
*/
export class SealosClient {
private readonly client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: env.SEALOS_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.SEALOS_KC}`
}
});
// Response interceptor for error handling
this.client.interceptors.response.use(
(response) => {
// Handle empty response for void operations
if (response.data === undefined || response.data === null) {
return response;
}
// Check API-level error code
if (response.data.code === 404) {
return Promise.reject({ status: 404, message: 'Resource not found' });
}
if (response.data.code && response.data.code !== 200) {
return Promise.reject(response.data.error || response.data.message || 'API error');
}
return response;
},
(error: AxiosError<{ error?: string; message?: string }>) => {
const status = error.response?.status;
const errorData = error.response?.data;
console.log(errorData, 2222);
if (status === 401 || status === 403) {
return Promise.reject(new Error('Authentication failed'));
}
if (status === 404) {
return Promise.reject({
status: 404,
message: errorData?.message || 'Resource not found'
});
}
if (status && status >= 500) {
return Promise.reject(new Error(errorData?.message || 'Server error'));
}
const message = errorData?.error || errorData?.message || error.message || 'Request failed';
return Promise.reject(new Error(message));
}
);
}
/**
* Create a new container with fixed configuration from environment variables
*/
async createContainer(params: CreateContainerInput): Promise<void> {
const validated = CreateContainerSchema.parse(params);
// Parse entrypoint configuration
let launchCommand: { command?: string; args?: string } | undefined;
if (containerConfig.entrypoint) {
try {
const parsed = JSON.parse(containerConfig.entrypoint);
if (Array.isArray(parsed) && parsed.length > 0) {
launchCommand = {
command: parsed[0],
args: parsed.slice(1).join(' ')
};
}
} catch {
// If not JSON, treat as direct command
launchCommand = { command: containerConfig.entrypoint };
}
}
await this.client
.post('/api/v1/app', {
name: validated.name,
image: {
imageName: containerConfig.image
},
resource: {
cpu: containerConfig.cpu,
memory: containerConfig.memory,
replicas: 1
},
ports: [
{
number: containerConfig.port,
exposesPublicDomain: containerConfig.exposesPublicDomain
}
],
launchCommand
})
.catch((err) => {
if (err.code === 409) {
return;
}
return Promise.reject(err);
});
}
/**
* Get container information by name
*/
async getContainer(name: string): Promise<ContainerInfo | null> {
try {
const response = await this.client.get(`/api/v1/app/${encodeURIComponent(name)}`);
const data = SealosContainerResponseSchema.parse(response.data.data);
return {
name: data.name,
image: data.image,
status: this.mapContainerStatus(data.status),
server: data.ports[0],
createdAt: data.createTime
};
} catch (err: unknown) {
if (err && typeof err === 'object' && 'status' in err && err.status === 404) {
return null;
}
throw err;
}
}
/**
* Pause a running container
*/
async pauseContainer(name: string): Promise<void> {
try {
await this.client.post(`/api/v1/app/${encodeURIComponent(name)}/pause`);
} catch (err: unknown) {
if (err && typeof err === 'object' && 'status' in err && err.status === 404) {
return;
}
throw err;
}
}
/**
* Resume/start a paused container
*/
async resumeContainer(name: string): Promise<void> {
try {
await this.client.post(`/api/v1/app/${encodeURIComponent(name)}/start`);
} catch (err: unknown) {
if (err && typeof err === 'object' && 'status' in err && err.status === 404) {
return;
}
throw err;
}
}
/**
* Delete a container
*/
async deleteContainer(name: string): Promise<void> {
try {
await this.client.delete(`/api/v1/app/${encodeURIComponent(name)}`);
} catch (err: unknown) {
if (err && typeof err === 'object' && 'status' in err && err.status === 404) {
return;
}
throw err;
}
}
/**
* Map Sealos API status to internal status
*/
private mapContainerStatus(status: {
replicas: number;
availableReplicas: number;
isPause: boolean;
}): ContainerStatus {
if (status.isPause) {
return {
state: 'Paused',
replicas: status.replicas,
availableReplicas: status.availableReplicas
};
}
if (status.availableReplicas > 0) {
return {
state: 'Running',
replicas: status.replicas,
availableReplicas: status.availableReplicas
};
}
return {
state: 'Creating',
replicas: status.replicas,
availableReplicas: status.availableReplicas
};
}
}
/**
* Create a new SealosClient instance
*/
export function createSealosClient(): SealosClient {
return new SealosClient();
}
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
const isTest = process.env.NODE_ENV === 'test';
export const env = createEnv({
server: {
PORT: z.coerce.number().default(3000),
TOKEN: isTest ? z.string().default('test-token') : z.string().min(1),
SEALOS_BASE_URL: z.string().url().default('https://applaunchpad.hzh.sealos.run'),
SEALOS_KC: isTest ? z.string().default('') : z.string().min(1),
// Container configuration
CONTAINER_IMAGE: isTest ? z.string().default('test-image') : z.string(),
CONTAINER_PORT: z.coerce.number().default(8080),
CONTAINER_CPU: z.coerce.number().default(0.5),
CONTAINER_MEMORY: z.coerce.number().default(1),
CONTAINER_ENTRYPOINT: z.string().optional(),
CONTAINER_EXPOSES_PUBLIC_DOMAIN: z
.string()
.default('false')
.transform((v) => v === 'true')
},
runtimeEnv: process.env
});
// Container configuration for SealosClient
export const containerConfig = {
image: env.CONTAINER_IMAGE,
port: env.CONTAINER_PORT,
cpu: env.CONTAINER_CPU,
memory: env.CONTAINER_MEMORY,
entrypoint: env.CONTAINER_ENTRYPOINT || '',
exposesPublicDomain: env.CONTAINER_EXPOSES_PUBLIC_DOMAIN
};
import { OpenAPIHono } from '@hono/zod-openapi';
import { apiReference } from '@scalar/hono-api-reference';
import { env } from './env';
import { authMiddleware, errorHandler, loggerMiddleware } from './middleware';
import { createSealosClient } from './clients';
import { createContainerRoutes, createSandboxRoutes } from './routes';
import { logger } from './utils';
// Create Hono app with OpenAPI support
const app = new OpenAPIHono();
// Global error handler
app.onError(errorHandler);
// Global logger middleware
app.use('*', loggerMiddleware);
// Create Sealos client
const sealosClient = createSealosClient();
// ==================== Public Routes ====================
// Health check endpoint (no auth required)
app.get('/health', (c) => {
return c.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// OpenAPI JSON document
app.doc('/openapi', {
openapi: '3.0.0',
info: {
title: 'Sandbox Server API',
version: '1.0.0',
description: 'API for managing sandbox containers via Sealos'
},
servers: [
{
url: `http://localhost:${env.PORT}`,
description: 'Local development server'
}
]
});
// Scalar API Reference UI
app.get(
'/openapi/ui',
apiReference({
url: '/openapi',
theme: 'default'
})
);
// ==================== Protected Routes ====================
// Create v1 router with authentication
const v1 = new OpenAPIHono();
v1.use('*', authMiddleware);
// Mount container routes
v1.route('/', createContainerRoutes(sealosClient));
// Mount sandbox routes
v1.route('/', createSandboxRoutes(sealosClient));
// Mount v1 router
app.route('/v1', v1);
// ==================== Start Server ====================
logger.info('Server', `Starting on port ${env.PORT}`);
logger.info('Server', `API Documentation: http://localhost:${env.PORT}/openapi/ui`);
export default {
port: env.PORT,
fetch: app.fetch
};
// Export app for testing
export { app };
import { createMiddleware } from 'hono/factory';
import { HTTPException } from 'hono/http-exception';
import { env } from '../env';
/**
* Bearer token authentication middleware
* Validates Authorization header: Bearer <token>
*/
export const authMiddleware = createMiddleware(async (c, next) => {
const authorization = c.req.header('Authorization');
if (!authorization) {
throw new HTTPException(401, { message: 'Authorization header is required' });
}
if (!authorization.startsWith('Bearer ')) {
throw new HTTPException(401, {
message: 'Invalid authorization format. Expected: Bearer <token>'
});
}
const token = authorization.slice(7);
if (token !== env.TOKEN) {
throw new HTTPException(401, { message: 'Invalid token' });
}
await next();
});
import type { ErrorHandler } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { ZodError } from 'zod';
import { setLoggerError } from './logger';
/**
* Global error handler
* Catches all errors and returns consistent JSON response
*/
export const errorHandler: ErrorHandler = (err, c) => {
// Handle HTTP exceptions
if (err instanceof HTTPException) {
setLoggerError(c.req.raw, err.message);
return c.json(
{
success: false,
message: err.message
},
err.status
);
}
// Handle Zod validation errors
if (err instanceof ZodError) {
const message = 'Validation error';
setLoggerError(c.req.raw, message);
return c.json(
{
success: false,
message,
errors: err.issues
},
400
);
}
// Handle generic errors
const message = err instanceof Error ? err.message : 'Internal Server Error';
setLoggerError(c.req.raw, message);
return c.json(
{
success: false,
message
},
500
);
};
export { authMiddleware } from './auth';
export { errorHandler } from './error';
export { loggerMiddleware, setLoggerError } from './logger';
import { createMiddleware } from 'hono/factory';
import { logger } from '../utils';
// Store for request timing and error info
const requestStore = new WeakMap<Request, { startTime: number; errorMessage?: string }>();
/**
* HTTP Logger middleware
* Logs request start and completion with timing information
*/
export const loggerMiddleware = createMiddleware(async (c, next) => {
const startTime = Date.now();
const method = c.req.method;
const path = c.req.path;
// Store timing info
requestStore.set(c.req.raw, { startTime });
// Log request start
logger.httpRequest(method, path);
await next();
// Log response
const duration = Date.now() - startTime;
const status = c.res.status;
const stored = requestStore.get(c.req.raw);
const errorMessage = status >= 400 ? stored?.errorMessage : undefined;
logger.httpResponse(method, path, status, duration, errorMessage);
// Cleanup
requestStore.delete(c.req.raw);
});
/**
* Set error message for logging (called from errorHandler)
*/
export function setLoggerError(req: Request, message: string): void {
const stored = requestStore.get(req);
if (stored) {
stored.errorMessage = message;
}
}
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
import {
CreateContainerSchema,
ContainerInfoResponseSchema,
SuccessResponseSchema,
ErrorResponseSchema
} from '../schemas';
import type { SealosClient } from '../clients';
// ==================== Route Definitions ====================
const createContainerRoute = createRoute({
method: 'post',
path: '/containers',
tags: ['Container'],
summary: 'Create a new container',
request: {
body: {
content: {
'application/json': {
schema: CreateContainerSchema
}
}
}
},
responses: {
200: {
content: {
'application/json': {
schema: SuccessResponseSchema
}
},
description: 'Container created successfully'
},
400: {
content: {
'application/json': {
schema: ErrorResponseSchema
}
},
description: 'Bad request'
}
}
});
const getContainerRoute = createRoute({
method: 'get',
path: '/containers/{name}',
tags: ['Container'],
summary: 'Get container information',
request: {
params: z.object({
name: z.string().openapi({ param: { name: 'name', in: 'path' }, example: 'my-container' })
})
},
responses: {
200: {
content: {
'application/json': {
schema: ContainerInfoResponseSchema
}
},
description: 'Container information'
},
404: {
content: {
'application/json': {
schema: ErrorResponseSchema
}
},
description: 'Container not found'
}
}
});
const pauseContainerRoute = createRoute({
method: 'post',
path: '/containers/{name}/pause',
tags: ['Container'],
summary: 'Pause a running container',
request: {
params: z.object({
name: z.string().openapi({ param: { name: 'name', in: 'path' }, example: 'my-container' })
})
},
responses: {
200: {
content: {
'application/json': {
schema: SuccessResponseSchema
}
},
description: 'Container paused successfully'
}
}
});
const startContainerRoute = createRoute({
method: 'post',
path: '/containers/{name}/start',
tags: ['Container'],
summary: 'Start a paused container',
request: {
params: z.object({
name: z.string().openapi({ param: { name: 'name', in: 'path' }, example: 'my-container' })
})
},
responses: {
200: {
content: {
'application/json': {
schema: SuccessResponseSchema
}
},
description: 'Container started successfully'
}
}
});
const deleteContainerRoute = createRoute({
method: 'delete',
path: '/containers/{name}',
tags: ['Container'],
summary: 'Delete a container',
request: {
params: z.object({
name: z.string().openapi({ param: { name: 'name', in: 'path' }, example: 'my-container' })
})
},
responses: {
200: {
content: {
'application/json': {
schema: SuccessResponseSchema
}
},
description: 'Container deleted successfully'
}
}
});
// ==================== Controller Factory ====================
export const createContainerRoutes = (sealosClient: SealosClient) => {
const app = new OpenAPIHono();
// POST /containers - Create container
app.openapi(createContainerRoute, async (c) => {
const body = c.req.valid('json');
await sealosClient.createContainer(body);
return c.json({ success: true as const }, 200);
});
// GET /containers/:name - Get container info
app.openapi(getContainerRoute, async (c) => {
const { name } = c.req.valid('param');
const container = await sealosClient.getContainer(name);
if (!container) {
return c.json({ success: false as const, message: 'Container not found' }, 404);
}
return c.json({ success: true as const, data: container }, 200);
});
// POST /containers/:name/pause - Pause container
app.openapi(pauseContainerRoute, async (c) => {
const { name } = c.req.valid('param');
await sealosClient.pauseContainer(name);
return c.json({ success: true as const }, 200);
});
// POST /containers/:name/start - Start container
app.openapi(startContainerRoute, async (c) => {
const { name } = c.req.valid('param');
await sealosClient.resumeContainer(name);
return c.json({ success: true as const }, 200);
});
// DELETE /containers/:name - Delete container
app.openapi(deleteContainerRoute, async (c) => {
const { name } = c.req.valid('param');
await sealosClient.deleteContainer(name);
return c.json({ success: true as const }, 200);
});
return app;
};
export { createContainerRoutes } from './container.route';
export { createSandboxRoutes } from './sandbox.route';
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';
import {
ExecRequestSchema,
ExecResultResponseSchema,
HealthCheckResponseSchema,
ErrorResponseSchema
} from '../schemas';
import { createSandboxClient, type SealosClient } from '../clients';
// ==================== Route Definitions ====================
const execRoute = createRoute({
method: 'post',
path: '/sandbox/{name}/exec',
tags: ['Sandbox'],
summary: 'Execute a command in the sandbox',
request: {
params: z.object({
name: z.string().openapi({ param: { name: 'name', in: 'path' }, example: 'my-container' })
}),
body: {
content: {
'application/json': {
schema: ExecRequestSchema
}
}
}
},
responses: {
200: {
content: {
'application/json': {
schema: ExecResultResponseSchema
}
},
description: 'Command executed successfully'
},
400: {
content: {
'application/json': {
schema: ErrorResponseSchema
}
},
description: 'Bad request'
},
404: {
content: {
'application/json': {
schema: ErrorResponseSchema
}
},
description: 'Container not found'
}
}
});
const healthRoute = createRoute({
method: 'get',
path: '/sandbox/{name}/health',
tags: ['Sandbox'],
summary: 'Check sandbox health',
request: {
params: z.object({
name: z.string().openapi({ param: { name: 'name', in: 'path' }, example: 'my-container' })
})
},
responses: {
200: {
content: {
'application/json': {
schema: HealthCheckResponseSchema
}
},
description: 'Health check result'
},
404: {
content: {
'application/json': {
schema: ErrorResponseSchema
}
},
description: 'Container not found'
}
}
});
// ==================== Controller Factory ====================
/**
* Factory function to create sandbox routes
* @param sealosClient - Sealos client to get container info for sandbox URL
*/
export const createSandboxRoutes = (sealosClient: SealosClient) => {
const app = new OpenAPIHono();
/**
* Get sandbox client by container name
* Retrieves container info to get the sandbox server URL
*/
const getSandboxClient = async (name: string) => {
const container = await sealosClient.getContainer(name);
if (!container || !container.server) {
throw new Error('Container not found or has no server info');
}
// Build sandbox URL from container server info
let baseUrl: string;
if (container.server.publicDomain && container.server.domain) {
baseUrl = `https://${container.server.publicDomain}.${container.server.domain}`;
} else {
baseUrl = `http://${container.server.serviceName}:${container.server.number}`;
}
return createSandboxClient(baseUrl);
};
// POST /sandbox/:name/exec - Execute command
app.openapi(execRoute, async (c) => {
const { name } = c.req.valid('param');
const body = c.req.valid('json');
try {
const sandboxClient = await getSandboxClient(name);
const result = await sandboxClient.exec(body);
return c.json({ success: true as const, data: result }, 200);
} catch (err) {
if (err instanceof Error && err.message.includes('not found')) {
return c.json({ success: false as const, message: 'Container not found' }, 404);
}
throw err;
}
});
// GET /sandbox/:name/health - Health check
app.openapi(healthRoute, async (c) => {
const { name } = c.req.valid('param');
try {
const sandboxClient = await getSandboxClient(name);
const healthy = await sandboxClient.isHealthy();
return c.json({ success: true as const, healthy }, 200);
} catch (err) {
if (err instanceof Error && err.message.includes('not found')) {
return c.json({ success: false as const, message: 'Container not found' }, 404);
}
throw err;
}
});
return app;
};
import type { z } from '@hono/zod-openapi';
export declare const SuccessResponseSchema: z.ZodObject<
{
success: z.ZodLiteral<true>;
},
z.core.$strip
>;
export type SuccessResponse = z.infer<typeof SuccessResponseSchema>;
export declare const ErrorResponseSchema: z.ZodObject<
{
success: z.ZodLiteral<false>;
message: z.ZodString;
},
z.core.$strip
>;
export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
export declare const NameParamSchema: z.ZodObject<
{
name: z.ZodString;
},
z.core.$strip
>;
export type NameParam = z.infer<typeof NameParamSchema>;
import { z } from '@hono/zod-openapi';
// Common response wrapper
export const SuccessResponseSchema = z.object({
success: z.literal(true)
});
export type SuccessResponse = z.infer<typeof SuccessResponseSchema>;
export const ErrorResponseSchema = z.object({
success: z.literal(false),
message: z.string()
});
export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
// Path parameter for container/sandbox name
export const NameParamSchema = z.object({
name: z
.string()
.min(1)
.openapi({ param: { name: 'name', in: 'path' }, example: 'my-container' })
});
export type NameParam = z.infer<typeof NameParamSchema>;
import type { z } from '@hono/zod-openapi';
export declare const CreateContainerSchema: z.ZodObject<
{
name: z.ZodString;
},
z.core.$strip
>;
export type CreateContainerInput = z.infer<typeof CreateContainerSchema>;
export declare const ContainerStatusSchema: z.ZodObject<
{
state: z.ZodEnum<{
Running: 'Running';
Creating: 'Creating';
Paused: 'Paused';
Error: 'Error';
Unknown: 'Unknown';
}>;
replicas: z.ZodOptional<z.ZodNumber>;
availableReplicas: z.ZodOptional<z.ZodNumber>;
},
z.core.$strip
>;
export type ContainerStatus = z.infer<typeof ContainerStatusSchema>;
export declare const ContainerServerSchema: z.ZodObject<
{
serviceName: z.ZodString;
number: z.ZodNumber;
publicDomain: z.ZodOptional<z.ZodString>;
domain: z.ZodOptional<z.ZodString>;
},
z.core.$strip
>;
export type ContainerServer = z.infer<typeof ContainerServerSchema>;
export declare const ContainerInfoSchema: z.ZodObject<
{
name: z.ZodString;
image: z.ZodObject<
{
imageName: z.ZodString;
},
z.core.$strip
>;
status: z.ZodObject<
{
state: z.ZodEnum<{
Running: 'Running';
Creating: 'Creating';
Paused: 'Paused';
Error: 'Error';
Unknown: 'Unknown';
}>;
replicas: z.ZodOptional<z.ZodNumber>;
availableReplicas: z.ZodOptional<z.ZodNumber>;
},
z.core.$strip
>;
server: z.ZodOptional<
z.ZodObject<
{
serviceName: z.ZodString;
number: z.ZodNumber;
publicDomain: z.ZodOptional<z.ZodString>;
domain: z.ZodOptional<z.ZodString>;
},
z.core.$strip
>
>;
createdAt: z.ZodOptional<z.ZodString>;
},
z.core.$strip
>;
export type ContainerInfo = z.infer<typeof ContainerInfoSchema>;
export declare const ContainerInfoResponseSchema: z.ZodObject<
{
success: z.ZodLiteral<true>;
data: z.ZodObject<
{
name: z.ZodString;
image: z.ZodObject<
{
imageName: z.ZodString;
},
z.core.$strip
>;
status: z.ZodObject<
{
state: z.ZodEnum<{
Running: 'Running';
Creating: 'Creating';
Paused: 'Paused';
Error: 'Error';
Unknown: 'Unknown';
}>;
replicas: z.ZodOptional<z.ZodNumber>;
availableReplicas: z.ZodOptional<z.ZodNumber>;
},
z.core.$strip
>;
server: z.ZodOptional<
z.ZodObject<
{
serviceName: z.ZodString;
number: z.ZodNumber;
publicDomain: z.ZodOptional<z.ZodString>;
domain: z.ZodOptional<z.ZodString>;
},
z.core.$strip
>
>;
createdAt: z.ZodOptional<z.ZodString>;
},
z.core.$strip
>;
},
z.core.$strip
>;
export type ContainerInfoResponse = z.infer<typeof ContainerInfoResponseSchema>;
export declare const SealosContainerResponseSchema: z.ZodObject<
{
name: z.ZodString;
image: z.ZodObject<
{
imageName: z.ZodString;
},
z.core.$strip
>;
createTime: z.ZodOptional<z.ZodString>;
status: z.ZodObject<
{
replicas: z.ZodCoercedNumber<unknown>;
availableReplicas: z.ZodCoercedNumber<unknown>;
isPause: z.ZodCoercedBoolean<unknown>;
},
z.core.$strip
>;
ports: z.ZodArray<
z.ZodObject<
{
serviceName: z.ZodString;
number: z.ZodCoercedNumber<unknown>;
publicDomain: z.ZodOptional<z.ZodString>;
domain: z.ZodOptional<z.ZodString>;
},
z.core.$strip
>
>;
},
z.core.$strip
>;
export type SealosContainerResponse = z.infer<typeof SealosContainerResponseSchema>;
import { z } from '@hono/zod-openapi';
// ==================== Request Schemas ====================
export const CreateContainerSchema = z.object({
name: z.string().min(1).openapi({ example: 'my-container' })
});
export type CreateContainerInput = z.infer<typeof CreateContainerSchema>;
// ==================== Response Schemas ====================
export const ContainerStatusSchema = z.object({
state: z.enum(['Running', 'Creating', 'Paused', 'Error', 'Unknown']),
replicas: z.number().optional(),
availableReplicas: z.number().optional()
});
export type ContainerStatus = z.infer<typeof ContainerStatusSchema>;
export const ContainerServerSchema = z.object({
serviceName: z.string(),
number: z.number(),
publicDomain: z.string().optional(),
domain: z.string().optional()
});
export type ContainerServer = z.infer<typeof ContainerServerSchema>;
export const ContainerInfoSchema = z.object({
name: z.string(),
image: z.object({
imageName: z.string()
}),
status: ContainerStatusSchema,
server: ContainerServerSchema.optional(),
createdAt: z.string().optional()
});
export type ContainerInfo = z.infer<typeof ContainerInfoSchema>;
export const ContainerInfoResponseSchema = z.object({
success: z.literal(true),
data: ContainerInfoSchema
});
export type ContainerInfoResponse = z.infer<typeof ContainerInfoResponseSchema>;
// ==================== Sealos API Response Schemas ====================
export const SealosContainerResponseSchema = z.object({
name: z.string(),
image: z.object({
imageName: z.string()
}),
createTime: z.string().optional(),
status: z.object({
replicas: z.coerce.number(),
availableReplicas: z.coerce.number(),
isPause: z.coerce.boolean()
}),
ports: z.array(
z.object({
serviceName: z.string(),
number: z.coerce.number(),
publicDomain: z.string().optional(),
domain: z.string().optional()
})
)
});
export type SealosContainerResponse = z.infer<typeof SealosContainerResponseSchema>;
export * from './common.schema';
export * from './container.schema';
export * from './sandbox.schema';
export * from './common.schema';
export * from './container.schema';
export * from './sandbox.schema';
import type { z } from '@hono/zod-openapi';
export declare const ExecRequestSchema: z.ZodObject<
{
command: z.ZodString;
cwd: z.ZodOptional<z.ZodString>;
},
z.core.$strip
>;
export type ExecRequest = z.infer<typeof ExecRequestSchema>;
export declare const HealthResponseSchema: z.ZodObject<
{
status: z.ZodString;
timestamp: z.ZodOptional<z.ZodString>;
},
z.core.$strip
>;
export type HealthResponse = z.infer<typeof HealthResponseSchema>;
export declare const ExecResponseSchema: z.ZodObject<
{
success: z.ZodBoolean;
stdout: z.ZodString;
stderr: z.ZodString;
exitCode: z.ZodNumber;
cwd: z.ZodOptional<z.ZodString>;
error: z.ZodOptional<z.ZodString>;
},
z.core.$strip
>;
export type ExecResponse = z.infer<typeof ExecResponseSchema>;
export declare const HealthCheckResponseSchema: z.ZodObject<
{
success: z.ZodLiteral<true>;
healthy: z.ZodBoolean;
},
z.core.$strip
>;
export type HealthCheckResponse = z.infer<typeof HealthCheckResponseSchema>;
export declare const ExecResultResponseSchema: z.ZodObject<
{
success: z.ZodLiteral<true>;
data: z.ZodObject<
{
success: z.ZodBoolean;
stdout: z.ZodString;
stderr: z.ZodString;
exitCode: z.ZodNumber;
cwd: z.ZodOptional<z.ZodString>;
error: z.ZodOptional<z.ZodString>;
},
z.core.$strip
>;
},
z.core.$strip
>;
export type ExecResultResponse = z.infer<typeof ExecResultResponseSchema>;
import { z } from '@hono/zod-openapi';
// ==================== Request Schemas ====================
export const ExecRequestSchema = z.object({
command: z.string().min(1).openapi({ example: 'ls -la' }),
cwd: z.string().optional().openapi({ example: '/app/sandbox' })
});
export type ExecRequest = z.infer<typeof ExecRequestSchema>;
// ==================== Response Schemas ====================
export const HealthResponseSchema = z.object({
status: z.string(),
timestamp: z.string().optional()
});
export type HealthResponse = z.infer<typeof HealthResponseSchema>;
export const ExecResponseSchema = z.object({
success: z.boolean(),
stdout: z.string(),
stderr: z.string(),
exitCode: z.number(),
cwd: z.string().optional(),
error: z.string().optional()
});
export type ExecResponse = z.infer<typeof ExecResponseSchema>;
export const HealthCheckResponseSchema = z.object({
success: z.literal(true),
healthy: z.boolean()
});
export type HealthCheckResponse = z.infer<typeof HealthCheckResponseSchema>;
export const ExecResultResponseSchema = z.object({
success: z.literal(true),
data: ExecResponseSchema
});
export type ExecResultResponse = z.infer<typeof ExecResultResponseSchema>;
export { logger } from './logger';
/**
* Logger utility for sandbox server
* Provides structured, formatted logging with color support
*/
// ANSI color codes
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
// Foreground colors
black: '\x1b[30m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
gray: '\x1b[90m'
};
type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
const levelColors: Record<LogLevel, string> = {
DEBUG: colors.gray,
INFO: colors.green,
WARN: colors.yellow,
ERROR: colors.red
};
const methodColors: Record<string, string> = {
GET: colors.cyan,
POST: colors.green,
PUT: colors.yellow,
PATCH: colors.yellow,
DELETE: colors.red
};
/**
* Format timestamp as HH:mm:ss.SSS
*/
function formatTime(date: Date): string {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
const ms = date.getMilliseconds().toString().padStart(3, '0');
return `${hours}:${minutes}:${seconds}.${ms}`;
}
/**
* Format duration with appropriate unit
*/
function formatDuration(ms: number): string {
if (ms < 1000) {
return `${ms}ms`;
}
return `${(ms / 1000).toFixed(2)}s`;
}
/**
* Get color for HTTP status code
*/
function getStatusColor(status: number): string {
if (status >= 500) return colors.red;
if (status >= 400) return colors.yellow;
if (status >= 300) return colors.cyan;
if (status >= 200) return colors.green;
return colors.white;
}
/**
* Core log function
*/
function log(
level: LogLevel,
category: string,
message: string,
meta?: Record<string, unknown>
): void {
const now = new Date();
const time = formatTime(now);
const levelColor = levelColors[level];
const levelStr = level.padEnd(5);
let output = `${colors.dim}${time}${colors.reset} ${levelColor}${levelStr}${colors.reset} ${colors.bright}[${category}]${colors.reset} ${message}`;
if (meta && Object.keys(meta).length > 0) {
const metaStr = Object.entries(meta)
.map(([k, v]) => `${colors.dim}${k}=${colors.reset}${v}`)
.join(' ');
output += ` ${metaStr}`;
}
console.log(output);
}
/**
* Logger interface
*/
export const logger = {
debug: (category: string, message: string, meta?: Record<string, unknown>) =>
log('DEBUG', category, message, meta),
info: (category: string, message: string, meta?: Record<string, unknown>) =>
log('INFO', category, message, meta),
warn: (category: string, message: string, meta?: Record<string, unknown>) =>
log('WARN', category, message, meta),
error: (category: string, message: string, meta?: Record<string, unknown>) =>
log('ERROR', category, message, meta),
/**
* Log HTTP request start
*/
httpRequest: (method: string, path: string) => {
const methodColor = methodColors[method] || colors.white;
const methodStr = method.padEnd(6);
log(
'INFO',
'HTTP',
`${colors.bright}-->${colors.reset} ${methodColor}${methodStr}${colors.reset} ${path}`
);
},
/**
* Log HTTP response
*/
httpResponse: (
method: string,
path: string,
status: number,
duration: number,
error?: string
) => {
const methodColor = methodColors[method] || colors.white;
const statusColor = getStatusColor(status);
const methodStr = method.padEnd(6);
const durationStr = formatDuration(duration);
const level: LogLevel = status >= 400 ? 'ERROR' : 'INFO';
let message = `${colors.bright}<--${colors.reset} ${methodColor}${methodStr}${colors.reset} ${path} ${statusColor}${status}${colors.reset} ${colors.dim}${durationStr}${colors.reset}`;
if (error) {
message += ` ${colors.red}${error}${colors.reset}`;
}
log(level, 'HTTP', message);
}
};
export default logger;
# Sealos Configuration
SEALOS_BASE_URL=https://applaunchpad.hzh.sealos.run
SEALOS_KC=
# Container Configuration (fixed for all containers)
CONTAINER_IMAGE=hub.hzh.sealos.run/ns-4gabgrbc/agent-sandbox:v0.0.7
CONTAINER_PORT=8080
CONTAINER_CPU=0.5
CONTAINER_MEMORY=1
# Entrypoint format: JSON array like '["/bin/bash","-c","script.sh"]' or plain command
CONTAINER_ENTRYPOINT='["/bin/bash -c","/home/devbox/project/entrypoint.sh prod"]'
# Whether to expose container to public domain
CONTAINER_EXPOSES_PUBLIC_DOMAIN=true
SDK_SERVER_URL=http://localhost:3000
SDK_TOKEN=test-token
\ No newline at end of file
# Ignore local test environment files
.env.test.local
# 测试说明
## 测试类型
### 1. 单元测试 (`test/app.test.ts`)
基础的 HTTP 端点测试,无需外部依赖。
```bash
bun run test
```
测试内容:
- `/health` 健康检查
- `/openapi` OpenAPI 文档
- Bearer Token 鉴权
### 2. 集成测试 (`test/integration/`)
测试与真实 Sealos API 的集成,需要配置环境变量。
#### 配置步骤
1. 复制环境变量模板:
```bash
cp test/.env.test.template test/.env.test.local
```
2. 编辑 `test/.env.test.local`,填写真实配置:
```env
# 服务器配置
PORT=3000
TOKEN=your-api-token
# Sealos 配置(使用测试环境)- 提供 SEALOS_KC 后集成测试自动运行
SEALOS_BASE_URL=https://your-sealos-api.com
SEALOS_KC=your-kubeconfig-token
# 测试镜像
TEST_IMAGE=nginx:alpine
TEST_SANDBOX_IMAGE=ghcr.io/your-org/sandbox-server:latest
```
3. 运行测试(集成测试会自动运行,如果配置了 SEALOS_KC):
```bash
bun run test
```
#### 测试内容
**容器生命周期测试** (`test/integration/container.test.ts`)
- ✅ 创建容器
- ✅ 获取容器信息
- ✅ 暂停容器
- ✅ 启动容器
- ✅ 删除容器
- ✅ 幂等性测试(重复创建、删除不存在的容器)
**沙盒操作测试** (`test/integration/sandbox.test.ts`)
- ✅ 健康检查
- ✅ 执行简单命令
- ✅ 捕获 stdout/stderr
- ✅ 工作目录设置
- ✅ 管道命令
- ✅ 多行脚本
- ✅ 错误处理
## 运行测试
### 仅运行单元测试
```bash
bun run test:run
```
### 运行所有测试(包括集成测试)
```bash
# 确保已配置 .env.test.local 并提供 SEALOS_KC
bun run test
```
### 运行特定测试文件
```bash
bun run test test/integration/container.test.ts
```
### 查看测试覆盖率
```bash
bun run test -- --coverage
```
## 注意事项
### 集成测试注意事项
1. **使用测试环境**
- 不要在生产环境运行集成测试
- 确保有足够的资源配额
2. **清理资源**
- 测试会自动清理创建的容器
- 如果测试中断,可能需要手动清理
3. **网络要求**
- 需要能访问 Sealos API
- 需要能拉取测试镜像
4. **超时设置**
- 容器启动可能需要 60-90 秒
- 某些测试设置了较长的超时时间
### 环境变量优先级
1. `.env.test.local` - 本地测试配置(不提交到 git)
2. 默认值 - 使用 mock 数据
### 跳过集成测试
如果 `SEALOS_KC` 未提供,集成测试会自动跳过。这样可以避免意外运行集成测试。
## 故障排查
### 测试失败:认证错误
- 检查 `SEALOS_KC` 是否有效
- 确认 token 有足够的权限
### 测试失败:超时
- 增加测试超时时间
- 检查网络连接
- 确认 Sealos 服务正常
### 测试失败:容器创建失败
- 检查资源配额
- 确认镜像可访问
- 查看 Sealos 日志
## 示例
### 运行完整测试流程
```bash
# 1. 配置环境变量
cp test/.env.test.template test/.env.test.local
# 编辑 test/.env.test.local
# 2. 运行测试(集成测试会自动运行)
bun run test
```
import { describe, it, expect } from 'vitest';
import { app } from '../src/index';
describe('App', () => {
describe('GET /health', () => {
it('should return health status', async () => {
const res = await app.request('/health');
expect(res.status).toBe(200);
const data = (await res.json()) as { status: string; timestamp: string };
expect(data.status).toBe('ok');
expect(data.timestamp).toBeDefined();
});
});
describe('GET /openapi', () => {
it('should return OpenAPI document', async () => {
const res = await app.request('/openapi');
expect(res.status).toBe(200);
const data = (await res.json()) as { openapi: string; info: { title: string } };
expect(data.openapi).toBe('3.0.0');
expect(data.info.title).toBe('Sandbox Server API');
});
});
describe('Protected routes', () => {
it('should return 401 without authorization header', async () => {
const res = await app.request('/v1/containers/test');
expect(res.status).toBe(401);
});
it('should return 401 with invalid token', async () => {
const res = await app.request('/v1/containers/test', {
headers: {
Authorization: 'Bearer invalid-token'
}
});
expect(res.status).toBe(401);
});
});
});
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
import { createSealosClient } from '../../src/clients';
import type { SealosClient } from '../../src/clients';
import { env, containerConfig } from '../../src/env';
/**
* Integration tests for Container lifecycle management.
*
* Tests run only when SEALOS_KC is provided in .env.test.local
*
* Required environment variables:
* - SEALOS_BASE_URL: Sealos API base URL
* - SEALOS_KC: Sealos kubeconfig token
* - CONTAINER_IMAGE: Docker image for container
* - CONTAINER_CPU: CPU resource
* - CONTAINER_MEMORY: Memory resource
*/
const sealosKc = env.SEALOS_KC;
describe.skipIf(!sealosKc)('Container Integration Tests', () => {
// Generate unique container name for test isolation
const testContainerName = `test-container-${Math.random().toString(36).substring(2, 8)}`;
let sealosClient: SealosClient;
beforeAll(() => {
sealosClient = createSealosClient();
});
afterAll(async () => {
// Cleanup: ensure container is deleted after tests
try {
await sealosClient.deleteContainer(testContainerName);
} catch {
// Ignore cleanup errors
}
});
describe('Container Lifecycle', () => {
it('should return null when getting non-existent container', async () => {
const info = await sealosClient.getContainer(testContainerName);
expect(info).toBeNull();
});
it('should create a new container', async () => {
await sealosClient.createContainer({ name: testContainerName });
// Verify container was created by getting its info
const info = await sealosClient.getContainer(testContainerName);
expect(info).not.toBeNull();
expect(info!.name).toBe(testContainerName);
// Image should match the configured image from environment
expect(info!.image.imageName).toContain(containerConfig.image.split(':')[0]);
});
it('should get container information', async () => {
const info = await sealosClient.getContainer(testContainerName);
expect(info).not.toBeNull();
expect(info!.name).toBe(testContainerName);
expect(info!.image).toBeDefined();
expect(info!.status).toBeDefined();
expect(['Creating', 'Running', 'Paused', 'Error', 'Unknown']).toContain(info!.status.state);
});
it('should wait for container to be running', async () => {
// Wait for container to be ready
await waitForContainerState(sealosClient, testContainerName, ['Running'], 20000);
const info = await sealosClient.getContainer(testContainerName);
expect(info!.status.state).toBe('Running');
}, 30000);
it('should pause a running container', async () => {
await sealosClient.pauseContainer(testContainerName);
// Wait and verify paused state
await waitForContainerState(sealosClient, testContainerName, ['Paused'], 30000);
const info = await sealosClient.getContainer(testContainerName);
expect(info!.status.state).toBe('Paused');
}, 60000);
it('should start/resume a paused container', async () => {
await sealosClient.resumeContainer(testContainerName);
// Wait and verify running state
await waitForContainerState(sealosClient, testContainerName, ['Running'], 20000);
const info = await sealosClient.getContainer(testContainerName);
expect(info!.status.state).toBe('Running');
}, 30000);
it('should delete the container', async () => {
await sealosClient.deleteContainer(testContainerName);
// Verify container no longer exists
await sleep(2000); // Give it time to delete
const info = await sealosClient.getContainer(testContainerName);
expect(info).toBeNull();
});
});
describe('Idempotent Operations', () => {
const idempotentTestName = `idempotent-${Math.random().toString(36).substring(2, 8)}`;
afterAll(async () => {
try {
await sealosClient.deleteContainer(idempotentTestName);
} catch {
// Ignore
}
});
it('should handle creating an already existing container', async () => {
// Create container
await sealosClient.createContainer({ name: idempotentTestName });
// Creating again should not throw (Sealos returns existing container)
await expect(
sealosClient.createContainer({ name: idempotentTestName })
).resolves.not.toThrow();
// Cleanup
await sealosClient.deleteContainer(idempotentTestName);
}, 60000);
it('should handle deleting a non-existent container', async () => {
const nonExistentName = `non-existent-${Math.random().toString(36).substring(2, 8)}`;
// Deleting non-existent container should not throw
await expect(sealosClient.deleteContainer(nonExistentName)).resolves.not.toThrow();
});
});
});
/**
* Helper function to wait for container to reach expected state
*/
async function waitForContainerState(
client: SealosClient,
name: string,
expectedStates: string[],
timeoutMs: number = 30000
): Promise<void> {
const startTime = Date.now();
const pollInterval = 2000;
while (Date.now() - startTime < timeoutMs) {
const info = await client.getContainer(name);
if (info && expectedStates.includes(info.status.state)) {
return;
}
await sleep(pollInterval);
}
throw new Error(
`Timeout waiting for container state. Expected: ${expectedStates.join(' or ')}, timeout: ${timeoutMs}ms`
);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
import { createSealosClient, createSandboxClient } from '../../src/clients';
import type { SealosClient, SandboxClient } from '../../src/clients';
import { env } from '../../src/env';
/**
* Integration tests for Sandbox operations (exec and health check).
*
* Tests run only when SEALOS_KC is provided in .env.test.local
*
* Required environment variables:
* - SEALOS_BASE_URL: Sealos API base URL
* - SEALOS_KC: Sealos kubeconfig token
* - SEALOS_IMAGE: Docker image with sandbox server (must have /health and /exec endpoints)
*/
describe.skipIf(!env.SEALOS_KC)('Sandbox Integration Tests', () => {
// Generate unique container name for test isolation
const testContainerName = `sandbox-test-${Math.random().toString(36).substring(2, 8)}`;
let sealosClient: SealosClient;
let sandboxClient: SandboxClient;
beforeAll(async () => {
sealosClient = createSealosClient();
// Create container with sandbox server
await sealosClient.createContainer({ name: testContainerName });
// Wait for container to be running
await waitForContainerState(sealosClient, testContainerName, ['Running'], 90000);
// Get container info to build sandbox URL
const containerInfo = await sealosClient.getContainer(testContainerName);
if (!containerInfo || !containerInfo.server) {
throw new Error('Failed to get container server info');
}
// Build sandbox URL
let sandboxUrl: string;
if (containerInfo.server.publicDomain && containerInfo.server.domain) {
sandboxUrl = `https://${containerInfo.server.publicDomain}.${containerInfo.server.domain}`;
} else {
sandboxUrl = `http://${containerInfo.server.serviceName}:${containerInfo.server.number}`;
}
sandboxClient = createSandboxClient(sandboxUrl);
// Give sandbox server time to start
await sleep(5000);
});
afterAll(async () => {
// Cleanup: delete test container
try {
await sealosClient.deleteContainer(testContainerName);
} catch {
// Ignore cleanup errors
}
});
describe('Health Check', () => {
it('should check sandbox health', async () => {
const healthy = await sandboxClient.isHealthy();
expect(typeof healthy).toBe('boolean');
});
it('should get health response', async () => {
const health = await sandboxClient.health();
expect(health).toBeDefined();
expect(health.status).toBeDefined();
});
});
describe('Command Execution', () => {
it('should execute a simple echo command', async () => {
const result = await sandboxClient.exec({
command: 'echo "Hello, World!"'
});
expect(result.stdout.trim()).toBe('Hello, World!');
expect(result.stderr).toBe('');
expect(result.exitCode).toBe(0);
});
it('should return correct exit code for successful command', async () => {
const result = await sandboxClient.exec({
command: 'true'
});
expect(result.exitCode).toBe(0);
});
it('should return non-zero exit code for failed command', async () => {
const result = await sandboxClient.exec({
command: 'false'
});
expect(result.exitCode).not.toBe(0);
});
it('should capture stderr output', async () => {
const result = await sandboxClient.exec({
command: 'echo "error message" >&2'
});
expect(result.stderr).toContain('error message');
expect(result.exitCode).toBe(0);
});
it('should capture both stdout and stderr', async () => {
const result = await sandboxClient.exec({
command: 'echo "out" && echo "err" >&2'
});
expect(result.stdout).toContain('out');
expect(result.stderr).toContain('err');
});
it('should execute command with working directory', async () => {
const result = await sandboxClient.exec({
command: 'pwd',
cwd: '/tmp'
});
expect(result.stdout.trim()).toBe('/tmp');
expect(result.exitCode).toBe(0);
});
it('should execute piped commands', async () => {
const result = await sandboxClient.exec({
command: 'echo "hello world" | tr "a-z" "A-Z"'
});
expect(result.stdout.trim()).toBe('HELLO WORLD');
expect(result.exitCode).toBe(0);
});
it('should handle command with special characters', async () => {
const result = await sandboxClient.exec({
command: 'echo "test$var\'quote\\"double"'
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toBeDefined();
});
it('should execute multi-line script', async () => {
const script = `
count=0
for i in 1 2 3; do
count=$((count + 1))
done
echo $count
`;
const result = await sandboxClient.exec({
command: script
});
expect(result.stdout.trim()).toBe('3');
expect(result.exitCode).toBe(0);
});
it('should handle command that does not exist', async () => {
const result = await sandboxClient.exec({
command: 'nonexistent_command_12345'
});
expect(result.exitCode).not.toBe(0);
expect(result.stderr.length > 0 || result.stdout.length > 0).toBe(true);
});
it('should handle empty command output', async () => {
const result = await sandboxClient.exec({
command: 'true'
});
expect(result.stdout).toBe('');
expect(result.exitCode).toBe(0);
});
it('should handle large output', async () => {
const result = await sandboxClient.exec({
command: 'seq 1 100'
});
expect(result.stdout).toContain('1\n');
expect(result.stdout).toMatch(/100(\n|$)/);
expect(result.exitCode).toBe(0);
});
it('should preserve environment in command', async () => {
const result = await sandboxClient.exec({
command: 'export MY_VAR="test123" && echo $MY_VAR'
});
expect(result.stdout.trim()).toBe('test123');
});
});
describe('Error Handling', () => {
it('should handle connection errors gracefully', async () => {
const invalidClient = createSandboxClient('http://invalid-url-12345.com');
await expect(invalidClient.health()).rejects.toThrow();
});
it('should handle command timeout', async () => {
// Execute a command that completes quickly to test proper execution
const result = await sandboxClient.exec({
command: 'sleep 0.1'
});
expect(result).toBeDefined();
expect(result.exitCode).toBe(0);
});
});
});
/**
* Helper function to wait for container to reach expected state
*/
async function waitForContainerState(
client: SealosClient,
name: string,
expectedStates: string[],
timeoutMs: number = 30000
): Promise<void> {
const startTime = Date.now();
const pollInterval = 2000;
while (Date.now() - startTime < timeoutMs) {
try {
const info = await client.getContainer(name);
if (info && expectedStates.includes(info.status.state)) {
return;
}
} catch {
// Ignore errors during polling
}
await sleep(pollInterval);
}
throw new Error(
`Timeout waiting for container state. Expected: ${expectedStates.join(' or ')}, timeout: ${timeoutMs}ms`
);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { authMiddleware } from '../../src/middleware/auth';
import { errorHandler } from '../../src/middleware/error';
describe('Auth Middleware', () => {
const createTestApp = () => {
const app = new Hono();
app.onError(errorHandler);
app.use('*', authMiddleware);
app.get('/protected', (c) => c.json({ success: true }));
return app;
};
describe('Authorization header validation', () => {
it('should return 401 when Authorization header is missing', async () => {
const app = createTestApp();
const res = await app.request('/protected');
expect(res.status).toBe(401);
const data = (await res.json()) as { message: string };
expect(data.message).toBe('Authorization header is required');
});
it('should return 401 when Authorization format is invalid (no Bearer prefix)', async () => {
const app = createTestApp();
const res = await app.request('/protected', {
headers: {
Authorization: 'test-token'
}
});
expect(res.status).toBe(401);
const data = (await res.json()) as { message: string };
expect(data.message).toBe('Invalid authorization format. Expected: Bearer <token>');
});
it('should return 401 when Authorization format is invalid (wrong prefix)', async () => {
const app = createTestApp();
const res = await app.request('/protected', {
headers: {
Authorization: 'Basic test-token'
}
});
expect(res.status).toBe(401);
const data = (await res.json()) as { message: string };
expect(data.message).toBe('Invalid authorization format. Expected: Bearer <token>');
});
});
describe('Token validation', () => {
it('should return 401 when token is invalid', async () => {
const app = createTestApp();
const res = await app.request('/protected', {
headers: {
Authorization: 'Bearer invalid-token'
}
});
expect(res.status).toBe(401);
const data = (await res.json()) as { message: string };
expect(data.message).toBe('Invalid token');
});
it('should return 401 when token is empty', async () => {
const app = createTestApp();
const res = await app.request('/protected', {
headers: {
Authorization: 'Bearer '
}
});
expect(res.status).toBe(401);
const data = (await res.json()) as { message: string };
// Note: 'Bearer ' (with trailing space but no token) is treated as invalid format
expect(data.message).toBe('Invalid authorization format. Expected: Bearer <token>');
});
it('should allow request with valid token', async () => {
const app = createTestApp();
const res = await app.request('/protected', {
headers: {
Authorization: 'Bearer test-token' // matches env.TOKEN in test mode
}
});
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean };
expect(data.success).toBe(true);
});
});
});
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { z } from 'zod';
import { errorHandler } from '../../src/middleware/error';
describe('Error Handler', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
const createTestApp = (errorToThrow: () => Error) => {
const app = new Hono();
app.onError(errorHandler);
app.get('/error', () => {
throw errorToThrow();
});
return app;
};
describe('HTTPException handling', () => {
it('should handle HTTPException with 401 status', async () => {
const app = createTestApp(() => new HTTPException(401, { message: 'Unauthorized' }));
const res = await app.request('/error');
expect(res.status).toBe(401);
const data = (await res.json()) as { success: boolean; message: string };
expect(data.success).toBe(false);
expect(data.message).toBe('Unauthorized');
});
it('should handle HTTPException with 403 status', async () => {
const app = createTestApp(() => new HTTPException(403, { message: 'Forbidden' }));
const res = await app.request('/error');
expect(res.status).toBe(403);
const data = (await res.json()) as { success: boolean; message: string };
expect(data.success).toBe(false);
expect(data.message).toBe('Forbidden');
});
it('should handle HTTPException with 404 status', async () => {
const app = createTestApp(() => new HTTPException(404, { message: 'Not Found' }));
const res = await app.request('/error');
expect(res.status).toBe(404);
const data = (await res.json()) as { success: boolean; message: string };
expect(data.success).toBe(false);
expect(data.message).toBe('Not Found');
});
it('should handle HTTPException with 500 status', async () => {
const app = createTestApp(() => new HTTPException(500, { message: 'Internal Server Error' }));
const res = await app.request('/error');
expect(res.status).toBe(500);
const data = (await res.json()) as { success: boolean; message: string };
expect(data.success).toBe(false);
expect(data.message).toBe('Internal Server Error');
});
});
describe('ZodError handling', () => {
it('should handle ZodError with single issue', async () => {
const schema = z.object({
name: z.string().min(1)
});
const app = createTestApp(() => {
const result = schema.safeParse({ name: '' });
if (!result.success) {
return result.error;
}
return new Error('Unexpected');
});
const res = await app.request('/error');
expect(res.status).toBe(400);
const data = (await res.json()) as {
success: boolean;
message: string;
errors: Array<{ code: string; path: string[] }>;
};
expect(data.success).toBe(false);
expect(data.message).toBe('Validation error');
expect(data.errors).toBeDefined();
expect(Array.isArray(data.errors)).toBe(true);
expect(data.errors.length).toBeGreaterThan(0);
});
it('should handle ZodError with multiple issues', async () => {
const schema = z.object({
name: z.string().min(1),
age: z.number().positive()
});
const app = createTestApp(() => {
const result = schema.safeParse({ name: '', age: -1 });
if (!result.success) {
return result.error;
}
return new Error('Unexpected');
});
const res = await app.request('/error');
expect(res.status).toBe(400);
const data = (await res.json()) as {
success: boolean;
message: string;
errors: Array<{ code: string; path: string[] }>;
};
expect(data.success).toBe(false);
expect(data.message).toBe('Validation error');
expect(data.errors.length).toBe(2);
});
});
describe('Generic Error handling', () => {
it('should handle generic Error with custom message', async () => {
const app = createTestApp(() => new Error('Something went wrong'));
const res = await app.request('/error');
expect(res.status).toBe(500);
const data = (await res.json()) as { success: boolean; message: string };
expect(data.success).toBe(false);
expect(data.message).toBe('Something went wrong');
});
it('should handle Error without message', async () => {
const app = createTestApp(() => new Error());
const res = await app.request('/error');
expect(res.status).toBe(500);
const data = (await res.json()) as { success: boolean; message: string };
expect(data.success).toBe(false);
// Empty error message results in empty string
expect(data.message).toBe('');
});
});
describe('Error logging', () => {
it('should log errors to console', async () => {
const error = new Error('Test error');
const app = createTestApp(() => error);
await app.request('/error');
expect(consoleSpy).toHaveBeenCalledWith('[Error]', error);
});
});
});
import { beforeAll, afterAll, vi } from 'vitest';
import { loadEnvFiles } from './utils/env';
// Set test environment
process.env.NODE_ENV = 'test';
// Load environment variables from .env.test.local if exists
loadEnvFiles({ envFileNames: ['.env.test.local'] });
beforeAll(() => {
// Additional setup if needed
});
afterAll(() => {
vi.restoreAllMocks();
});
import { existsSync, readFileSync } from 'fs';
import { resolve } from 'path';
type LoadVectorEnvOptions = {
envFileNames?: string[];
};
const parseEnvFile = (filePath: string) => {
const content = readFileSync(filePath, 'utf-8');
const lines = content.split('\n');
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const separatorIndex = line.indexOf('=');
if (separatorIndex === -1) continue;
const key = line.slice(0, separatorIndex).trim();
const value = line.slice(separatorIndex + 1).trim();
if (!key || process.env[key]) continue;
process.env[key] = value;
}
};
export const loadEnvFiles = (options: LoadVectorEnvOptions = {}) => {
const envFileNames = options.envFileNames ?? ['.env.test.local'];
// __dirname is test/utils/, go up one level to test/
const baseDir = resolve(__dirname, '..');
for (const envFileName of envFileNames) {
const filePath = resolve(baseDir, envFileName);
if (existsSync(filePath)) {
parseEnvFile(filePath);
}
}
};
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"lib": ["ES2022"],
"types": ["bun-types"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "test/**/*"],
"exclude": ["node_modules"]
}
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./test/setup.ts'],
include: ['test/**/*.test.ts'],
hookTimeout: 120000,
coverage: {
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.schema.ts', 'src/sdk/**/*']
}
},
resolve: {
alias: {
'@': './src'
}
}
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment