feat: Implement pluggable multi-provider sandbox architecture (#12820)

## Summary

Implement a flexible sandbox provider system supporting both
self-managed (Docker) and SaaS (Aliyun Code Interpreter) backends for
secure code execution in agent workflows.

**Key Changes:**
-  Aliyun Code Interpreter provider using official
`agentrun-sdk>=0.0.16`
-  Self-managed provider with gVisor (runsc) security
-  Arguments parameter support for dynamic code execution
-  Database-only configuration (removed fallback logic)
-  Configuration scripts for quick setup

Issue #12479

## Features

### 🔌 Provider Abstraction Layer

**1. Self-Managed Provider** (`agent/sandbox/providers/self_managed.py`)
- Wraps existing executor_manager HTTP API
- gVisor (runsc) for secure container isolation
- Configurable pool size, timeout, retry logic
- Languages: Python, Node.js, JavaScript
- ⚠️ **Requires**: gVisor installation, Docker, base images

**2. Aliyun Code Interpreter**
(`agent/sandbox/providers/aliyun_codeinterpreter.py`)
- SaaS integration using official agentrun-sdk
- Serverless microVM execution with auto-authentication
- Hard timeout: 30 seconds max
- Credentials: `AGENTRUN_ACCESS_KEY_ID`, `AGENTRUN_ACCESS_KEY_SECRET`,
`AGENTRUN_ACCOUNT_ID`, `AGENTRUN_REGION`
- Automatically wraps code to call `main()` function

**3. E2B Provider** (`agent/sandbox/providers/e2b.py`)
- Placeholder for future integration

### ⚙️ Configuration System

- `conf/system_settings.json`: Default provider =
`aliyun_codeinterpreter`
- `agent/sandbox/client.py`: Enforces database-only configuration
- Admin UI: `/admin/sandbox-settings`
- Configuration validation via `validate_config()` method
- Health checks for all providers

### 🎯 Key Capabilities

**Arguments Parameter Support:**
All providers support passing arguments to `main()` function:
```python
# User code
def main(name: str, count: int) -> dict:
    return {"message": f"Hello {name}!" * count}

# Executed with: arguments={"name": "World", "count": 3}
# Result: {"message": "Hello World!Hello World!Hello World!"}
```

**Self-Describing Providers:**
Each provider implements `get_config_schema()` returning form
configuration for Admin UI

**Error Handling:**
Structured `ExecutionResult` with stdout, stderr, exit_code,
execution_time

## Configuration Scripts

Two scripts for quick Aliyun sandbox setup:

**Shell Script (requires jq):**
```bash
source scripts/configure_aliyun_sandbox.sh
```

**Python Script (interactive):**
```bash
python3 scripts/configure_aliyun_sandbox.py
```

## Testing

```bash
# Unit tests
uv run pytest agent/sandbox/tests/test_providers.py -v

# Aliyun provider tests
uv run pytest agent/sandbox/tests/test_aliyun_codeinterpreter.py -v

# Integration tests (requires credentials)
uv run pytest agent/sandbox/tests/test_aliyun_codeinterpreter_integration.py -v

# Quick SDK validation
python3 agent/sandbox/tests/verify_sdk.py
```

**Test Coverage:**
- 30 unit tests for provider abstraction
- Provider-specific tests for Aliyun
- Integration tests with real API
- Security tests for executor_manager

## Documentation

- `docs/develop/sandbox_spec.md` - Complete architecture specification
- `agent/sandbox/tests/MIGRATION_GUIDE.md` - Migration from legacy
sandbox
- `agent/sandbox/tests/QUICKSTART.md` - Quick start guide
- `agent/sandbox/tests/README.md` - Testing documentation

## Breaking Changes

⚠️ **Migration Required:**

1. **Directory Move**: `sandbox/` → `agent/sandbox/`
   - Update imports: `from sandbox.` → `from agent.sandbox.`

2. **Mandatory Configuration**: 
   - SystemSettings must have `sandbox.provider_type` configured
   - Removed fallback default values
- Configuration must exist in database (from
`conf/system_settings.json`)

3. **Aliyun Credentials**:
   - Requires `AGENTRUN_*` environment variables (not `ALIYUN_*`)
   - `AGENTRUN_ACCOUNT_ID` is now required (Aliyun primary account ID)

4. **Self-Managed Provider**:
   - gVisor (runsc) must be installed for security
   - Install: `go install gvisor.dev/gvisor/runsc@latest`

## Database Schema Changes

```python
# SystemSettings.value: CharField → TextField
api/db/db_models.py: Changed for unlimited config length

# SystemSettingsService.get_by_name(): Fixed query precision
api/db/services/system_settings_service.py: startswith → exact match
```

## Files Changed

### Backend (Python)
- `agent/sandbox/providers/base.py` - SandboxProvider ABC interface
- `agent/sandbox/providers/manager.py` - ProviderManager
- `agent/sandbox/providers/self_managed.py` - Self-managed provider
- `agent/sandbox/providers/aliyun_codeinterpreter.py` - Aliyun provider
- `agent/sandbox/providers/e2b.py` - E2B provider (placeholder)
- `agent/sandbox/client.py` - Unified client (enforces DB-only config)
- `agent/tools/code_exec.py` - Updated to use provider system
- `admin/server/services.py` - SandboxMgr with registry & validation
- `admin/server/routes.py` - 5 sandbox API endpoints
- `conf/system_settings.json` - Default: aliyun_codeinterpreter
- `api/db/db_models.py` - TextField for SystemSettings.value
- `api/db/services/system_settings_service.py` - Exact match query

### Frontend (TypeScript/React)
- `web/src/pages/admin/sandbox-settings.tsx` - Settings UI
- `web/src/services/admin-service.ts` - Sandbox service functions
- `web/src/services/admin.service.d.ts` - Type definitions
- `web/src/utils/api.ts` - Sandbox API endpoints

### Documentation
- `docs/develop/sandbox_spec.md` - Architecture spec
- `agent/sandbox/tests/MIGRATION_GUIDE.md` - Migration guide
- `agent/sandbox/tests/QUICKSTART.md` - Quick start
- `agent/sandbox/tests/README.md` - Testing guide

### Configuration Scripts
- `scripts/configure_aliyun_sandbox.sh` - Shell script (jq)
- `scripts/configure_aliyun_sandbox.py` - Python script

### Tests
- `agent/sandbox/tests/test_providers.py` - 30 unit tests
- `agent/sandbox/tests/test_aliyun_codeinterpreter.py` - Provider tests
- `agent/sandbox/tests/test_aliyun_codeinterpreter_integration.py` -
Integration tests
- `agent/sandbox/tests/verify_sdk.py` - SDK validation

## Architecture

```
Admin UI → Admin API → SandboxMgr → ProviderManager → [SelfManaged|Aliyun|E2B]
                                      ↓
                                  SystemSettings
```

## Usage

### 1. Configure Provider

**Via Admin UI:**
1. Navigate to `/admin/sandbox-settings`
2. Select provider (Aliyun Code Interpreter / Self-Managed)
3. Fill in configuration
4. Click "Test Connection" to verify
5. Click "Save" to apply

**Via Configuration Scripts:**
```bash
# Aliyun provider
export AGENTRUN_ACCESS_KEY_ID="xxx"
export AGENTRUN_ACCESS_KEY_SECRET="yyy"
export AGENTRUN_ACCOUNT_ID="zzz"
export AGENTRUN_REGION="cn-shanghai"
source scripts/configure_aliyun_sandbox.sh
```

### 2. Restart Service

```bash
cd docker
docker compose restart ragflow-server
```

### 3. Execute Code in Agent

```python
from agent.sandbox.client import execute_code

result = execute_code(
    code='def main(name: str) -> dict: return {"message": f"Hello {name}!"}',
    language="python",
    timeout=30,
    arguments={"name": "World"}
)

print(result.stdout)  # {"message": "Hello World!"}
```

## Troubleshooting

### "Container pool is busy" (Self-Managed)
- **Cause**: Pool exhausted (default: 1 container in `.env`)
- **Fix**: Increase `SANDBOX_EXECUTOR_MANAGER_POOL_SIZE` to 5+

### "Sandbox provider type not configured"
- **Cause**: Database missing configuration
- **Fix**: Run config script or set via Admin UI

### "gVisor not found"
- **Cause**: runsc not installed
- **Fix**: `go install gvisor.dev/gvisor/runsc@latest && sudo cp
~/go/bin/runsc /usr/local/bin/`

### Aliyun authentication errors
- **Cause**: Wrong environment variable names
- **Fix**: Use `AGENTRUN_*` prefix (not `ALIYUN_*`)

## Checklist

- [x] All tests passing (30 unit tests + integration tests)
- [x] Documentation updated (spec, migration guide, quickstart)
- [x] Type definitions added (TypeScript)
- [x] Admin UI implemented
- [x] Configuration validation
- [x] Health checks implemented
- [x] Error handling with structured results
- [x] Breaking changes documented
- [x] Configuration scripts created
- [x] gVisor requirements documented

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Zhichang Yu
2026-01-28 13:28:21 +08:00
committed by GitHub
parent b57c82b122
commit fd11aca8e5
72 changed files with 6914 additions and 404 deletions

View File

@ -2,12 +2,39 @@ import { ExternalToast, toast } from 'sonner';
const configuration: ExternalToast = { duration: 2500, position: 'top-center' };
type MessageOptions = {
message: string;
description?: string;
duration?: number;
};
const message = {
success: (msg: string) => {
toast.success(msg, configuration);
},
error: (msg: string) => {
toast.error(msg, configuration);
error: (msg: string | MessageOptions, data?: ExternalToast) => {
let messageText: string;
let options: ExternalToast = { ...configuration };
if (typeof msg === 'object') {
// Object-style call: message.error({ message: '...', description: '...', duration: 3 })
messageText = msg.message;
if (msg.description) {
messageText += `\n${msg.description}`;
}
if (msg.duration !== undefined) {
options.duration = msg.duration * 1000; // Convert to milliseconds
}
} else {
// String-style call: message.error('text', { description: '...' })
messageText = msg;
if (data?.description) {
messageText += `\n${data.description}`;
}
options = { ...options, ...data };
}
toast.error(messageText, options);
},
warning: (msg: string) => {
toast.warning(msg, configuration);

View File

@ -2451,6 +2451,7 @@ Important structured information may include: names, dates, locations, events, k
serviceStatus: 'Service status',
userManagement: 'User management',
sandboxSettings: 'Sandbox settings',
registrationWhitelist: 'Registration whitelist',
roles: 'Roles',
monitoring: 'Monitoring',

View File

@ -10,6 +10,7 @@ import {
LucideSquareUserRound,
LucideUserCog,
LucideUserStar,
LucideZap,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@ -45,6 +46,11 @@ const AdminNavigationLayout = () => {
name: t('admin.userManagement'),
icon: <LucideUserCog className="size-[1em]" />,
},
{
path: Routes.AdminSandboxSettings,
name: t('admin.sandboxSettings'),
icon: <LucideZap className="size-[1em]" />,
},
...(IS_ENTERPRISE
? [
{

View File

@ -0,0 +1,486 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
LucideCloud,
LucideLoader2,
LucideServer,
LucideZap,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
getSandboxConfig,
getSandboxProviderSchema,
listSandboxProviders,
setSandboxConfig,
testSandboxConnection,
} from '@/services/admin-service';
import message from '@/components/ui/message';
// Provider icons mapping
const PROVIDER_ICONS: Record<string, React.ElementType> = {
self_managed: LucideServer,
aliyun_codeinterpreter: LucideCloud,
e2b: LucideZap,
};
function AdminSandboxSettings() {
const { t } = useTranslation();
const queryClient = useQueryClient();
// State
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [configValues, setConfigValues] = useState<Record<string, unknown>>({});
const [testModalOpen, setTestModalOpen] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
details?: {
exit_code: number;
execution_time: number;
stdout: string;
stderr: string;
};
} | null>(null);
// Fetch providers list
const { data: providers = [], isLoading: providersLoading } = useQuery({
queryKey: ['admin/listSandboxProviders'],
queryFn: async () => (await listSandboxProviders()).data.data,
});
// Fetch current config
const { data: currentConfig, isLoading: configLoading } = useQuery({
queryKey: ['admin/getSandboxConfig'],
queryFn: async () => (await getSandboxConfig()).data.data,
});
// Fetch provider schema when provider is selected
const { data: providerSchema = {} } = useQuery({
queryKey: ['admin/getSandboxProviderSchema', selectedProvider],
queryFn: async () =>
(await getSandboxProviderSchema(selectedProvider!)).data.data,
enabled: !!selectedProvider,
});
// Set config mutation
const setConfigMutation = useMutation({
mutationFn: async (params: {
providerType: string;
config: Record<string, unknown>;
}) => (await setSandboxConfig(params)).data,
onSuccess: () => {
message.success('Sandbox configuration updated successfully');
queryClient.invalidateQueries({ queryKey: ['admin/getSandboxConfig'] });
},
onError: (error: Error) => {
message.error(`Failed to update configuration: ${error.message}`);
},
});
// Test connection mutation
const testConnectionMutation = useMutation({
mutationFn: async (params: {
providerType: string;
config: Record<string, unknown>;
}) => (await testSandboxConnection(params)).data.data,
onSuccess: (data) => {
setTestResult(data);
},
onError: (error: Error) => {
setTestResult({ success: false, message: error.message });
},
});
// Initialize state when current config is loaded
useEffect(() => {
if (currentConfig) {
setSelectedProvider(currentConfig.provider_type);
setConfigValues(currentConfig.config || {});
}
}, [currentConfig]);
// Apply schema defaults when provider schema changes
useEffect(() => {
if (providerSchema && Object.keys(providerSchema).length > 0) {
setConfigValues((prev) => {
const mergedConfig = { ...prev };
// Apply schema defaults for any missing fields
Object.entries(providerSchema).forEach(([fieldName, schema]) => {
if (
mergedConfig[fieldName] === undefined &&
schema.default !== undefined
) {
mergedConfig[fieldName] = schema.default;
}
});
return mergedConfig;
});
}
}, [providerSchema]);
// Handle provider change
const handleProviderChange = (providerId: string) => {
setSelectedProvider(providerId);
// Force refetch config and schema from backend when switching providers
queryClient.invalidateQueries({ queryKey: ['admin/getSandboxConfig'] });
queryClient.invalidateQueries({
queryKey: ['admin/getSandboxProviderSchema'],
});
};
// Handle config value change
const handleConfigValueChange = (fieldName: string, value: unknown) => {
setConfigValues((prev) => ({ ...prev, [fieldName]: value }));
};
// Handle save
const handleSave = () => {
if (!selectedProvider) return;
setConfigMutation.mutate({
providerType: selectedProvider,
config: configValues,
});
};
// Handle test connection
const handleTestConnection = () => {
if (!selectedProvider) return;
setTestModalOpen(true);
setTestResult(null);
testConnectionMutation.mutate({
providerType: selectedProvider,
config: configValues,
});
};
// Render config field based on schema
const renderConfigField = (
fieldName: string,
schema: AdminService.SandboxConfigField,
) => {
const value = configValues[fieldName] ?? schema.default ?? '';
const isSecret = schema.secret ?? false;
switch (schema.type) {
case 'string':
if (isSecret) {
return (
<Input
type="password"
id={fieldName}
placeholder={schema.placeholder}
value={value as string}
onChange={(e) =>
handleConfigValueChange(fieldName, e.target.value)
}
/>
);
}
return (
<Textarea
id={fieldName}
placeholder={schema.placeholder}
value={value as string}
onChange={(e) => handleConfigValueChange(fieldName, e.target.value)}
rows={
schema.description?.includes('endpoint') ||
schema.description?.includes('URL')
? 1
: 3
}
/>
);
case 'integer':
return (
<Input
type="number"
id={fieldName}
min={schema.min}
max={schema.max}
value={value as number}
onChange={(e) =>
handleConfigValueChange(fieldName, parseInt(e.target.value) || 0)
}
/>
);
case 'boolean':
return (
<Switch
id={fieldName}
checked={value as boolean}
onCheckedChange={(checked) =>
handleConfigValueChange(fieldName, checked)
}
/>
);
default:
return null;
}
};
if (providersLoading || configLoading) {
return (
<div className="flex items-center justify-center h-96">
<LucideLoader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
);
}
const selectedProviderData = providers.find((p) => p.id === selectedProvider);
const ProviderIcon = selectedProvider
? PROVIDER_ICONS[selectedProvider] || LucideServer
: LucideServer;
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold">Sandbox Settings</h3>
<p className="text-sm text-muted-foreground">
Configure your code execution sandbox provider. The sandbox is used by
the Code component in agents.
</p>
</div>
{/* Provider Selection */}
<Card>
<CardHeader>
<CardTitle>Provider Selection</CardTitle>
<CardDescription>
Choose a sandbox provider for code execution
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{providers.map((provider) => {
const Icon = PROVIDER_ICONS[provider.id] || LucideServer;
return (
<div
key={provider.id}
className={`relative rounded-lg border p-4 cursor-pointer transition-all hover:bg-accent ${
selectedProvider === provider.id
? 'border-primary bg-primary/5'
: 'border-border'
}`}
onClick={() => handleProviderChange(provider.id)}
>
<div className="flex items-start space-x-3">
<Icon className="w-5 h-5 mt-0.5 text-muted-foreground" />
<div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold">{provider.name}</h4>
<p className="text-xs text-muted-foreground mt-1">
{provider.description}
</p>
<div className="flex flex-wrap gap-1 mt-2">
{provider.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-secondary text-secondary-foreground"
>
{tag}
</span>
))}
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Provider Configuration */}
{selectedProvider && selectedProviderData && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex-1">
<CardTitle className="flex items-center gap-2">
<ProviderIcon className="w-5 h-5" />
{selectedProviderData.name} Configuration
</CardTitle>
<CardDescription>
Configure the connection settings for{' '}
{selectedProviderData.name}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleSave}
disabled={setConfigMutation.isPending}
size="sm"
>
{setConfigMutation.isPending ? (
<>
<LucideLoader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
'Save Configuration'
)}
</Button>
<Button
onClick={handleTestConnection}
disabled={testConnectionMutation.isPending}
variant="outline"
size="sm"
>
{testConnectionMutation.isPending ? (
<>
<LucideLoader2 className="w-4 h-4 mr-2 animate-spin" />
Testing...
</>
) : (
'Test Connection'
)}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{Object.entries(providerSchema).map(([fieldName, schema]) => (
<div key={fieldName} className="space-y-2">
<Label htmlFor={fieldName}>
{schema.label || fieldName}
{schema.required && (
<span className="text-destructive ml-1">*</span>
)}
</Label>
{renderConfigField(fieldName, schema)}
{schema.description && (
<p className="text-xs text-muted-foreground">
{schema.description}
</p>
)}
{schema.type === 'integer' &&
(schema.min !== undefined || schema.max !== undefined) && (
<p className="text-xs text-muted-foreground">
{schema.min !== undefined && `Minimum: ${schema.min}`}
{schema.min !== undefined &&
schema.max !== undefined &&
' • '}
{schema.max !== undefined && `Maximum: ${schema.max}`}
</p>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Test Result Modal */}
<Dialog open={testModalOpen} onOpenChange={setTestModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Connection Test Result</DialogTitle>
<DialogDescription>
{testResult === null
? 'Testing connection to sandbox provider...'
: testResult.success
? 'Successfully connected to sandbox provider'
: 'Failed to connect to sandbox provider'}
</DialogDescription>
</DialogHeader>
{testResult === null ? (
<div className="flex items-center justify-center py-8">
<LucideLoader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-4">
{/* Summary message */}
<div
className={`p-4 rounded-lg ${
testResult.success
? 'bg-green-50 text-green-900 dark:bg-green-900/20 dark:text-green-100'
: 'bg-red-50 text-red-900 dark:bg-red-900/20 dark:text-red-100'
}`}
>
<p className="text-sm whitespace-pre-wrap">
{testResult.message}
</p>
</div>
{/* Detailed execution results */}
{testResult.details && (
<div className="space-y-3">
{/* Exit code and execution time */}
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="p-2 bg-muted rounded">
<span className="font-medium">Exit Code:</span>{' '}
{testResult.details.exit_code}
</div>
<div className="p-2 bg-muted rounded">
<span className="font-medium">Execution Time:</span>{' '}
{testResult.details.execution_time?.toFixed(2)}s
</div>
</div>
{/* Standard output */}
{testResult.details.stdout && (
<div className="p-3 bg-muted rounded">
<p className="text-xs font-medium mb-2 text-muted-foreground">
Standard Output:
</p>
<pre className="text-xs whitespace-pre-wrap break-words font-mono">
{testResult.details.stdout}
</pre>
</div>
)}
{/* Standard error (stack traces) */}
{testResult.details.stderr && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
<p className="text-xs font-medium mb-2 text-red-900 dark:text-red-100">
Error Output / Stack Trace:
</p>
<pre className="text-xs whitespace-pre-wrap break-words font-mono text-red-900 dark:text-red-100">
{testResult.details.stderr}
</pre>
</div>
)}
</div>
)}
</div>
)}
<DialogFooter>
<Button onClick={() => setTestModalOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default AdminSandboxSettings;

View File

@ -60,6 +60,7 @@ export enum Routes {
Admin = '/admin',
AdminServices = `${Admin}/services`,
AdminUserManagement = `${Admin}/users`,
AdminSandboxSettings = `${Admin}/sandbox-settings`,
AdminWhitelist = `${Admin}/whitelist`,
AdminRoles = `${Admin}/roles`,
AdminMonitoring = `${Admin}/monitoring`,
@ -419,6 +420,10 @@ const routeConfig = [
path: Routes.AdminUserManagement,
Component: lazy(() => import('@/pages/admin/users')),
},
{
path: Routes.AdminSandboxSettings,
Component: lazy(() => import('@/pages/admin/sandbox-settings')),
},
...(IS_ENTERPRISE
? [
{

View File

@ -1,7 +1,7 @@
import { history } from '@/utils/simple-history-util';
import { message, notification } from 'antd';
import axios from 'axios';
import message from '@/components/ui/message';
import { Authorization } from '@/constants/authorization';
import i18n from '@/locales/config';
import { Routes } from '@/routes';
@ -41,38 +41,34 @@ request.interceptors.response.use(
if (data?.code === 100) {
message.error(data?.message);
} else if (data?.code === 401) {
notification.error({
message: data?.message,
message.error(data?.message, {
description: data?.message,
duration: 3,
});
authorizationUtil.removeAll();
history.push(Routes.Admin);
window.location.reload();
} else if (data?.code && data.code !== 0) {
notification.error({
message: `${i18n.t('message.hint')}: ${data?.code}`,
message.error(`${i18n.t('message.hint')}: ${data?.code}`, {
description: data?.message,
duration: 3,
});
}
return response;
},
(error) => {
const { response, message } = error;
const { response } = error;
const { data } = response ?? {};
if (error.message === 'Failed to fetch') {
notification.error({
message.error({
description: i18n.t('message.networkAnomalyDescription'),
message: i18n.t('message.networkAnomaly'),
});
} else if (data?.code === 100) {
message.error(data?.message);
} else if (response.status === 401 || data?.code === 401) {
notification.error({
message.error({
message: data?.message || response.statusText,
description:
data?.message || RetcodeMessage[response?.status as ResultCode],
@ -83,13 +79,13 @@ request.interceptors.response.use(
history.push(Routes.Admin);
window.location.reload();
} else if (data?.code && data.code !== 0) {
notification.error({
message.error({
message: `${i18n.t('message.hint')}: ${data?.code}`,
description: data?.message,
duration: 3,
});
} else if (response.status) {
notification.error({
message.error({
message: `${i18n.t('message.requestError')} ${response.status}: ${response.config.url}`,
description:
RetcodeMessage[response.status as ResultCode] || response.statusText,
@ -138,6 +134,12 @@ const {
adminImportWhitelist,
adminGetSystemVersion,
adminListSandboxProviders,
adminGetSandboxProviderSchema,
adminGetSandboxConfig,
adminSetSandboxConfig,
adminTestSandboxConnection,
} = api;
type ResponseData<D = NonNullable<unknown>> = {
@ -270,3 +272,49 @@ export const importWhitelistFromExcel = (file: File) => {
export const getSystemVersion = () =>
request.get<ResponseData<{ version: string }>>(adminGetSystemVersion);
// Sandbox settings APIs
export const listSandboxProviders = () =>
request.get<ResponseData<AdminService.SandboxProvider[]>>(
adminListSandboxProviders,
);
export const getSandboxProviderSchema = (providerId: string) =>
request.get<ResponseData<Record<string, AdminService.SandboxConfigField>>>(
adminGetSandboxProviderSchema(providerId),
);
export const getSandboxConfig = () =>
request.get<ResponseData<AdminService.SandboxConfig>>(adminGetSandboxConfig);
export const setSandboxConfig = (params: {
providerType: string;
config: Record<string, unknown>;
}) =>
request.post<ResponseData<AdminService.SandboxConfig>>(
adminSetSandboxConfig,
{
provider_type: params.providerType,
config: params.config,
},
);
export const testSandboxConnection = (params: {
providerType: string;
config: Record<string, unknown>;
}) =>
request.post<
ResponseData<{
success: boolean;
message: string;
details?: {
exit_code: number;
execution_time: number;
stdout: string;
stderr: string;
};
}>
>(adminTestSandboxConnection, {
provider_type: params.providerType,
config: params.config,
});

View File

@ -166,4 +166,29 @@ declare module AdminService {
update_date: string;
update_time: number;
};
// Sandbox settings types
export type SandboxProvider = {
id: string;
name: string;
description: string;
tags: string[];
};
export type SandboxConfigField = {
type: 'string' | 'integer' | 'boolean' | 'json';
required?: boolean;
label?: string;
placeholder?: string;
default?: string | number | boolean;
min?: number;
max?: number;
description?: string;
secret?: boolean;
};
export type SandboxConfig = {
provider_type: string;
config: Record<string, unknown>;
};
}

View File

@ -312,4 +312,12 @@ export default {
adminImportWhitelist: `${ExternalApi}${api_host}/admin/whitelist/batch`,
adminGetSystemVersion: `${ExternalApi}${api_host}/admin/version`,
// Sandbox settings
adminListSandboxProviders: `${ExternalApi}${api_host}/admin/sandbox/providers`,
adminGetSandboxProviderSchema: (providerId: string) =>
`${ExternalApi}${api_host}/admin/sandbox/providers/${providerId}/schema`,
adminGetSandboxConfig: `${ExternalApi}${api_host}/admin/sandbox/config`,
adminSetSandboxConfig: `${ExternalApi}${api_host}/admin/sandbox/config`,
adminTestSandboxConnection: `${ExternalApi}${api_host}/admin/sandbox/test`,
};