diff --git a/.env.example b/.env.example
index 8758810bed..39946ddc0e 100644
--- a/.env.example
+++ b/.env.example
@@ -223,6 +223,29 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# The LobeChat agents market index url
# AGENTS_INDEX_URL=https://chat-agents.lobehub.com
+# #######################################
+# ######### Cloud Sandbox Service #######
+# #######################################
+
+# Sandbox provider for built-in code execution, shell, file operations, and export.
+# Supported values: market, onlyboxes
+# SANDBOX_PROVIDER=market
+
+# Required when SANDBOX_PROVIDER=onlyboxes. Base URL of the Onlyboxes console API, without /api/v1.
+# ONLYBOXES_BASE_URL=https://onlyboxes.example.com
+
+# Required when SANDBOX_PROVIDER=onlyboxes. Must match Onlyboxes CONSOLE_JIT_SIGNING_KEY.
+# ONLYBOXES_JIT_SIGNING_KEY=onlyboxes-jit-signing-secret
+
+# Optional JIT token issuer. Defaults to APP_URL.
+# ONLYBOXES_JIT_ISSUER=https://lobehub.example.com
+
+# Optional JIT token TTL in seconds.
+# ONLYBOXES_JIT_TTL_SEC=1800
+
+# Optional terminal session lease in seconds for the Onlyboxes provider.
+# ONLYBOXES_LEASE_TTL_SEC=900
+
# #######################################
# ########### Plugin Service ############
# #######################################
diff --git a/Dockerfile b/Dockerfile
index 46cb454d63..8916bd9667 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -210,6 +210,14 @@ ENV NEXT_PUBLIC_S3_DOMAIN="" \
S3_ENABLE_PATH_STYLE="" \
S3_SET_ACL=""
+# Cloud Sandbox
+ENV SANDBOX_PROVIDER="" \
+ ONLYBOXES_BASE_URL="" \
+ ONLYBOXES_JIT_ISSUER="" \
+ ONLYBOXES_JIT_SIGNING_KEY="" \
+ ONLYBOXES_JIT_TTL_SEC="" \
+ ONLYBOXES_LEASE_TTL_SEC=""
+
# Model Variables
ENV \
# AI21
diff --git a/docs/self-hosting/environment-variables.mdx b/docs/self-hosting/environment-variables.mdx
index ee936fac1b..e35f6aa264 100644
--- a/docs/self-hosting/environment-variables.mdx
+++ b/docs/self-hosting/environment-variables.mdx
@@ -19,11 +19,13 @@ LobeHub provides some additional configuration options when deployed, which can
-
+
-
+
-
+
+
+
## Building a Custom Image with Overridden `NEXT_PUBLIC` Variables
diff --git a/docs/self-hosting/environment-variables.zh-CN.mdx b/docs/self-hosting/environment-variables.zh-CN.mdx
index d685f2dc18..ba9194f915 100644
--- a/docs/self-hosting/environment-variables.zh-CN.mdx
+++ b/docs/self-hosting/environment-variables.zh-CN.mdx
@@ -13,13 +13,15 @@ tags:
LobeHub 在部署时提供了一些额外的配置项,你可以使用环境变量进行自定义设置。
-
+
-
+
-
+
-
+
-
+
+
+
diff --git a/docs/self-hosting/environment-variables/cloud-sandbox.mdx b/docs/self-hosting/environment-variables/cloud-sandbox.mdx
new file mode 100644
index 0000000000..e1899b3a78
--- /dev/null
+++ b/docs/self-hosting/environment-variables/cloud-sandbox.mdx
@@ -0,0 +1,136 @@
+---
+title: Configuring Cloud Sandbox
+description: Configure the built-in Cloud Sandbox provider, including Market and self-hosted Onlyboxes.
+tags:
+ - Cloud Sandbox
+ - Onlyboxes
+ - Self-hosting
+---
+
+# Configuring Cloud Sandbox
+
+Cloud Sandbox powers the built-in code execution, shell command, file operation, and file export tools. By default, LobeHub uses the Market sandbox. Self-hosted deployments can switch the same tool surface to an Onlyboxes-compatible sandbox provider.
+
+## Core Environment Variables
+
+### `SANDBOX_PROVIDER`
+
+- Type: Optional
+- Description: Selects the server-side sandbox provider.
+- Default: `market`
+- Example: `onlyboxes`
+
+Supported values:
+
+- `market`: Use the existing Market sandbox.
+- `onlyboxes`: Use an Onlyboxes-compatible self-hosted sandbox console.
+
+### `MARKET_BASE_URL`
+
+- Type: Optional
+- Description: Base URL of the Market service. Leave it unset when using the official Market; set it only when connecting to a self-hosted or dedicated Market service.
+- Default: `https://market.lobehub.com`
+- Example: `https://market.example.com`
+
+### `MARKET_TRUSTED_CLIENT_ID`
+
+- Type: Optional
+- Description: Market Trusted Client ID used by the LobeHub server to call Market capabilities on behalf of the current user. It must be registered by the target Market service.
+- Default: -
+- Example: `lobechat-com`
+
+### `MARKET_TRUSTED_CLIENT_SECRET`
+
+- Type: Optional
+- Description: Shared secret for the Market Trusted Client. It must match the target Market service configuration.
+- Default: -
+- Example: `your-market-trusted-client-secret`
+
+### `ONLYBOXES_BASE_URL`
+
+- Type: Required when `SANDBOX_PROVIDER=onlyboxes`
+- Description: Base URL of the Onlyboxes console API. Do not include `/api/v1`.
+- Default: -
+- Example: `https://onlyboxes.example.com`
+
+### `ONLYBOXES_JIT_SIGNING_KEY`
+
+- Type: Required when `SANDBOX_PROVIDER=onlyboxes`
+- Description: HMAC signing key used to mint Onlyboxes MCP JIT bearer tokens. It must match the Onlyboxes console `CONSOLE_JIT_SIGNING_KEY`.
+- Default: -
+- Example: `onlyboxes-jit-signing-secret`
+
+### `ONLYBOXES_JIT_ISSUER`
+
+- Type: Optional
+- Description: Issuer used in Onlyboxes JIT token claims.
+- Default: `APP_URL`
+- Example: `https://lobehub.example.com`
+
+### `ONLYBOXES_JIT_TTL_SEC`
+
+- Type: Optional
+- Description: Lifetime of each Onlyboxes JIT token minted by LobeHub.
+- Default: `1800`
+- Example: `900`
+
+### `ONLYBOXES_LEASE_TTL_SEC`
+
+- Type: Optional
+- Description: Lease duration for persistent terminal sessions created by the Cloud Sandbox provider.
+- Default: `900`
+- Example: `3600`
+
+## Market Configuration
+
+By default, LobeHub uses the official Market sandbox and does not require extra sandbox configuration:
+
+```bash
+# SANDBOX_PROVIDER=market
+```
+
+To explicitly use Market, or to connect to a self-hosted or dedicated Market service, configure:
+
+```bash
+SANDBOX_PROVIDER=market
+MARKET_BASE_URL=https://market.example.com
+```
+
+If that Market service requires the LobeHub server to call sandbox, credential, or skill capabilities on behalf of the current user, also configure Trusted Client credentials:
+
+```bash
+MARKET_TRUSTED_CLIENT_ID=lobechat-com
+MARKET_TRUSTED_CLIENT_SECRET=your-market-trusted-client-secret
+```
+
+`MARKET_TRUSTED_CLIENT_ID` must be registered in the Market service's trusted client allowlist, and `MARKET_TRUSTED_CLIENT_SECRET` must match the shared secret configured on the Market service. Without Trusted Client credentials, Market capabilities that require authentication continue to use the existing user authorization flow.
+
+## Onlyboxes Runtime Requirements
+
+The configured Onlyboxes worker should expose `terminalExec` and `terminalResource`. LobeHub uses `terminalExec` as the compatibility layer for shell commands, code execution, and file operations, and uses `terminalResource` for file export through a pre-signed upload URL.
+
+For feature parity with the Market sandbox, the terminal runtime image should include:
+
+- `python3`, used by file operation wrappers and Python execution
+- `node`, used by JavaScript execution
+- `npx` with access to `tsx`, used by TypeScript execution
+- Standard shell utilities such as `base64`, `find`, and `grep`
+
+Minimum configuration for using Onlyboxes:
+
+```bash
+SANDBOX_PROVIDER=onlyboxes
+ONLYBOXES_BASE_URL=https://onlyboxes.example.com
+ONLYBOXES_JIT_SIGNING_KEY=onlyboxes-jit-signing-secret
+```
+
+Set the same secret on the Onlyboxes console:
+
+```bash
+CONSOLE_JIT_SIGNING_KEY=onlyboxes-jit-signing-secret
+```
+
+
+ File export still writes the exported artifact to the configured LobeHub S3 storage. Configure the
+ S3 environment variables when users need to download files generated inside the sandbox.
+
diff --git a/docs/self-hosting/environment-variables/cloud-sandbox.zh-CN.mdx b/docs/self-hosting/environment-variables/cloud-sandbox.zh-CN.mdx
new file mode 100644
index 0000000000..ff8c831051
--- /dev/null
+++ b/docs/self-hosting/environment-variables/cloud-sandbox.zh-CN.mdx
@@ -0,0 +1,136 @@
+---
+title: 配置云端沙箱
+description: 配置内置云端沙箱能力,包括 Market 和自托管 Onlyboxes。
+tags:
+ - 云端沙箱
+ - Onlyboxes
+ - 自托管
+---
+
+# 配置云端沙箱
+
+云端沙箱用于内置的代码执行、Shell 命令、文件操作和文件导出工具。默认情况下,LobeHub 使用 Market 沙箱;自托管部署可以把同一套工具能力切换到兼容 Onlyboxes 的沙箱提供方。
+
+## 核心环境变量
+
+### `SANDBOX_PROVIDER`
+
+- 类型:可选
+- 描述:选择服务端使用的沙箱提供方。
+- 默认值:`market`
+- 示例:`onlyboxes`
+
+支持的取值:
+
+- `market`:使用现有 Market 沙箱。
+- `onlyboxes`:使用兼容 Onlyboxes 的自托管沙箱 Console。
+
+### `MARKET_BASE_URL`
+
+- 类型:可选
+- 描述:Market 服务的基础 URL。使用官方 Market 时无需配置;仅当你需要连接自托管或专用 Market 服务时设置。
+- 默认值:`https://market.lobehub.com`
+- 示例:`https://market.example.com`
+
+### `MARKET_TRUSTED_CLIENT_ID`
+
+- 类型:可选
+- 描述:Market Trusted Client 的客户端 ID,用于让 LobeHub 服务端代表当前用户调用 Market 能力。需要由对应 Market 服务登记。
+- 默认值:-
+- 示例:`lobechat-com`
+
+### `MARKET_TRUSTED_CLIENT_SECRET`
+
+- 类型:可选
+- 描述:Market Trusted Client 的共享密钥,必须与对应 Market 服务端配置一致。
+- 默认值:-
+- 示例:`your-market-trusted-client-secret`
+
+### `ONLYBOXES_BASE_URL`
+
+- 类型:当 `SANDBOX_PROVIDER=onlyboxes` 时必填
+- 描述:Onlyboxes Console API 的基础 URL,不需要包含 `/api/v1`。
+- 默认值:-
+- 示例:`https://onlyboxes.example.com`
+
+### `ONLYBOXES_JIT_SIGNING_KEY`
+
+- 类型:当 `SANDBOX_PROVIDER=onlyboxes` 时必填
+- 描述:用于签发 Onlyboxes MCP JIT Bearer Token 的 HMAC 密钥,必须与 Onlyboxes Console 的 `CONSOLE_JIT_SIGNING_KEY` 一致。
+- 默认值:-
+- 示例:`onlyboxes-jit-signing-secret`
+
+### `ONLYBOXES_JIT_ISSUER`
+
+- 类型:可选
+- 描述:Onlyboxes JIT Token claims 中使用的签发方。
+- 默认值:`APP_URL`
+- 示例:`https://lobehub.example.com`
+
+### `ONLYBOXES_JIT_TTL_SEC`
+
+- 类型:可选
+- 描述:LobeHub 签发的每个 Onlyboxes JIT Token 的有效期。
+- 默认值:`1800`
+- 示例:`900`
+
+### `ONLYBOXES_LEASE_TTL_SEC`
+
+- 类型:可选
+- 描述:云端沙箱提供方创建持久终端会话时使用的租约时长。
+- 默认值:`900`
+- 示例:`3600`
+
+## Market 配置
+
+默认情况下,LobeHub 使用官方 Market 沙箱,不需要额外配置:
+
+```bash
+# SANDBOX_PROVIDER=market
+```
+
+如果你需要显式使用 Market,或者连接自托管 / 专用 Market 服务,可以配置:
+
+```bash
+SANDBOX_PROVIDER=market
+MARKET_BASE_URL=https://market.example.com
+```
+
+如果该 Market 服务要求 LobeHub 服务端代表当前用户调用沙箱、凭据或技能能力,还需要配置 Trusted Client:
+
+```bash
+MARKET_TRUSTED_CLIENT_ID=lobechat-com
+MARKET_TRUSTED_CLIENT_SECRET=your-market-trusted-client-secret
+```
+
+`MARKET_TRUSTED_CLIENT_ID` 需要在 Market 服务端的可信客户端白名单中,`MARKET_TRUSTED_CLIENT_SECRET` 需要与 Market 服务端共享密钥一致。未配置 Trusted Client 时,Market 侧需要认证的能力会继续使用现有的用户授权流程。
+
+## Onlyboxes 运行时要求
+
+配置的 Onlyboxes worker 需要暴露 `terminalExec` 和 `terminalResource`。LobeHub 使用 `terminalExec` 作为 Shell 命令、代码执行和文件操作的兼容层,并使用 `terminalResource` 通过预签名上传 URL 导出文件。
+
+为了和 Market 沙箱保持能力对等,终端运行镜像应包含:
+
+- `python3`,用于文件操作封装和 Python 执行
+- `node`,用于 JavaScript 执行
+- `npx` 以及可用的 `tsx`,用于 TypeScript 执行
+- `base64`、`find`、`grep` 等常见 Shell 工具
+
+使用 Onlyboxes 时的最小配置:
+
+```bash
+SANDBOX_PROVIDER=onlyboxes
+ONLYBOXES_BASE_URL=https://onlyboxes.example.com
+ONLYBOXES_JIT_SIGNING_KEY=onlyboxes-jit-signing-secret
+```
+
+Onlyboxes Console 侧需要配置同一个密钥:
+
+```bash
+CONSOLE_JIT_SIGNING_KEY=onlyboxes-jit-signing-secret
+```
+
+
+ 文件导出仍会把沙箱内产物写入 LobeHub 配置的 S3 存储。如果用户需要下载沙箱生成的文件,请同时配置 S3
+ 相关环境变量。
+
diff --git a/package.json b/package.json
index b9c0805cbf..16af8fcbba 100644
--- a/package.json
+++ b/package.json
@@ -277,6 +277,7 @@
"@lobechat/python-interpreter": "workspace:*",
"@lobechat/shared-tool-ui": "workspace:*",
"@lobechat/ssrf-safe-fetch": "workspace:*",
+ "@lobechat/tool-runtime": "workspace:*",
"@lobechat/utils": "workspace:*",
"@lobechat/web-crawler": "workspace:*",
"@lobehub/analytics": "^1.6.2",
diff --git a/packages/builtin-tool-cloud-sandbox/src/ExecutionRuntime/index.ts b/packages/builtin-tool-cloud-sandbox/src/ExecutionRuntime/index.ts
index 11ffd5429d..dce15a1578 100644
--- a/packages/builtin-tool-cloud-sandbox/src/ExecutionRuntime/index.ts
+++ b/packages/builtin-tool-cloud-sandbox/src/ExecutionRuntime/index.ts
@@ -18,7 +18,7 @@ import type {
*
* Dependency Injection:
* - Client: Inject codeInterpreterService (uses tRPC client)
- * - Server: Inject ServerSandboxService (uses MarketSDK directly)
+ * - Server: Inject configured sandbox provider (Market, Onlyboxes, etc.)
*/
export class CloudSandboxExecutionRuntime extends ComputerRuntime {
private sandboxService: ISandboxService;
diff --git a/packages/builtin-tool-cloud-sandbox/src/manifest.ts b/packages/builtin-tool-cloud-sandbox/src/manifest.ts
index 7116ee4514..b9891fc9aa 100644
--- a/packages/builtin-tool-cloud-sandbox/src/manifest.ts
+++ b/packages/builtin-tool-cloud-sandbox/src/manifest.ts
@@ -184,6 +184,7 @@ export const CloudSandboxManifest: BuiltinToolManifest = {
},
},
{
+ defaultTimeoutMs: 120_000,
description:
'Execute a shell command and return its output. Supports both synchronous and background execution with timeout control.',
humanIntervention: 'required',
diff --git a/packages/builtin-tool-cloud-sandbox/src/systemRole.ts b/packages/builtin-tool-cloud-sandbox/src/systemRole.ts
index 15e44f97e3..294667a5d9 100644
--- a/packages/builtin-tool-cloud-sandbox/src/systemRole.ts
+++ b/packages/builtin-tool-cloud-sandbox/src/systemRole.ts
@@ -7,7 +7,7 @@ export const systemPrompt = `You have access to a Cloud Sandbox that provides a
- Each conversation topic has its own isolated session
- Sessions may expire after inactivity; files will be recreated if needed
- The sandbox has its own isolated file system starting at the root directory
-- Commands will time out after 60 seconds by default
+- Commands will time out after 120 seconds by default
- **Default shell is /bin/sh** (typically dash or ash), NOT bash. The \`source\` command may not work as expected. If you need bash-specific features or \`source\`, wrap your command with bash: \`bash -c "source ~/.creds/env && your_command"\`
**Credential Injection Locations:**
diff --git a/packages/builtin-tool-cloud-sandbox/src/types/service.ts b/packages/builtin-tool-cloud-sandbox/src/types/service.ts
index 131aeb3867..d68b24c009 100644
--- a/packages/builtin-tool-cloud-sandbox/src/types/service.ts
+++ b/packages/builtin-tool-cloud-sandbox/src/types/service.ts
@@ -14,7 +14,7 @@ export interface SandboxCallToolResult {
* Result of exporting and uploading a file from sandbox
*/
export interface SandboxExportFileResult {
- error?: { message: string };
+ error?: { message: string; name?: string };
fileId?: string;
filename: string;
mimeType?: string;
@@ -29,7 +29,7 @@ export interface SandboxExportFileResult {
* Context (topicId, userId) is bound at service creation time, not passed per-call.
* This allows CloudSandboxExecutionRuntime to work on both client and server:
* - Client: Implemented via tRPC client (codeInterpreterService)
- * - Server: Implemented via MarketSDK directly (ServerSandboxService)
+ * - Server: Implemented via the configured sandbox provider
*/
export interface ISandboxService {
/**
diff --git a/packages/tool-runtime/src/ComputerRuntime.ts b/packages/tool-runtime/src/ComputerRuntime.ts
index 68eac9f2e7..5ba9cf0ede 100644
--- a/packages/tool-runtime/src/ComputerRuntime.ts
+++ b/packages/tool-runtime/src/ComputerRuntime.ts
@@ -307,6 +307,7 @@ export abstract class ComputerRuntime {
}
const r = result.result || {};
+ const commandSuccess = typeof r.success === 'boolean' ? r.success : result.success;
const state: RunCommandState = {
commandId: r.commandId || r.shell_id,
@@ -316,7 +317,7 @@ export abstract class ComputerRuntime {
output: r.output,
stderr: r.stderr,
stdout: r.stdout,
- success: result.success,
+ success: commandSuccess,
};
const content = formatCommandResult({
@@ -325,7 +326,7 @@ export abstract class ComputerRuntime {
shellId: r.commandId || r.shell_id,
stderr: r.stderr,
stdout: r.stdout || r.output,
- success: result.success,
+ success: commandSuccess,
});
return { content, state, success: true };
@@ -346,19 +347,21 @@ export abstract class ComputerRuntime {
}
const r = result.result || {};
+ const outputSuccess = typeof r.success === 'boolean' ? r.success : result.success;
const state: GetCommandOutputState = {
error: r.error,
exitCode: r.exitCode ?? r.exit_code,
newOutput: r.newOutput || r.output,
- success: result.success,
+ running: r.running ?? false,
+ success: outputSuccess,
};
const content = formatCommandOutput({
error: r.error,
exitCode: r.exitCode ?? r.exit_code,
output: r.newOutput || r.output,
- success: result.success,
+ success: outputSuccess,
});
return { content, state, success: true };
@@ -379,16 +382,19 @@ export abstract class ComputerRuntime {
});
}
+ const killSuccess =
+ typeof result.result?.success === 'boolean' ? result.result.success : result.success;
+
const state: KillCommandState = {
commandId: args.commandId,
error: result.result?.error,
- success: result.success,
+ success: killSuccess,
};
const content = formatKillResult({
error: result.result?.error,
shellId: args.commandId,
- success: result.success,
+ success: killSuccess,
});
return { content, state, success: true };
diff --git a/packages/tool-runtime/src/types.ts b/packages/tool-runtime/src/types.ts
index 246bf3216d..a0e875ba80 100644
--- a/packages/tool-runtime/src/types.ts
+++ b/packages/tool-runtime/src/types.ts
@@ -196,6 +196,7 @@ export interface GetCommandOutputState {
error?: string;
exitCode?: number;
newOutput?: string;
+ running?: boolean;
success: boolean;
}
diff --git a/src/envs/__tests__/sandbox.test.ts b/src/envs/__tests__/sandbox.test.ts
new file mode 100644
index 0000000000..10dde1ad35
--- /dev/null
+++ b/src/envs/__tests__/sandbox.test.ts
@@ -0,0 +1,52 @@
+// @vitest-environment node
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+describe('getSandboxConfig', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ delete process.env.SANDBOX_PROVIDER;
+ delete process.env.ONLYBOXES_BASE_URL;
+ delete process.env.ONLYBOXES_JIT_ISSUER;
+ delete process.env.ONLYBOXES_JIT_SIGNING_KEY;
+ delete process.env.ONLYBOXES_JIT_TTL_SEC;
+ delete process.env.ONLYBOXES_LEASE_TTL_SEC;
+ });
+
+ it('should treat docker empty string defaults as unset optional values', async () => {
+ process.env.SANDBOX_PROVIDER = '';
+ process.env.ONLYBOXES_BASE_URL = '';
+ process.env.ONLYBOXES_JIT_ISSUER = '';
+ process.env.ONLYBOXES_JIT_SIGNING_KEY = '';
+ process.env.ONLYBOXES_JIT_TTL_SEC = '';
+ process.env.ONLYBOXES_LEASE_TTL_SEC = '';
+
+ const { getSandboxConfig } = await import('../sandbox');
+ const config = getSandboxConfig();
+
+ expect(config.SANDBOX_PROVIDER).toBeUndefined();
+ expect(config.ONLYBOXES_BASE_URL).toBeUndefined();
+ expect(config.ONLYBOXES_JIT_ISSUER).toBeUndefined();
+ expect(config.ONLYBOXES_JIT_SIGNING_KEY).toBeUndefined();
+ expect(config.ONLYBOXES_JIT_TTL_SEC).toBeUndefined();
+ expect(config.ONLYBOXES_LEASE_TTL_SEC).toBeUndefined();
+ });
+
+ it('should parse configured sandbox values', async () => {
+ process.env.SANDBOX_PROVIDER = 'onlyboxes';
+ process.env.ONLYBOXES_BASE_URL = 'https://onlyboxes.example.com';
+ process.env.ONLYBOXES_JIT_ISSUER = 'lobehub-test';
+ process.env.ONLYBOXES_JIT_SIGNING_KEY = 'jit-signing-key';
+ process.env.ONLYBOXES_JIT_TTL_SEC = '900';
+ process.env.ONLYBOXES_LEASE_TTL_SEC = '3600';
+
+ const { getSandboxConfig } = await import('../sandbox');
+ const config = getSandboxConfig();
+
+ expect(config.SANDBOX_PROVIDER).toBe('onlyboxes');
+ expect(config.ONLYBOXES_BASE_URL).toBe('https://onlyboxes.example.com');
+ expect(config.ONLYBOXES_JIT_ISSUER).toBe('lobehub-test');
+ expect(config.ONLYBOXES_JIT_SIGNING_KEY).toBe('jit-signing-key');
+ expect(config.ONLYBOXES_JIT_TTL_SEC).toBe(900);
+ expect(config.ONLYBOXES_LEASE_TTL_SEC).toBe(3600);
+ });
+});
diff --git a/src/envs/sandbox.ts b/src/envs/sandbox.ts
new file mode 100644
index 0000000000..f469d057dd
--- /dev/null
+++ b/src/envs/sandbox.ts
@@ -0,0 +1,33 @@
+import { createEnv } from '@t3-oss/env-core';
+import { z } from 'zod';
+
+const emptyStringToUndefined = (value: unknown) => (value === '' ? undefined : value);
+
+export const getSandboxConfig = () => {
+ return createEnv({
+ runtimeEnv: {
+ ONLYBOXES_BASE_URL: process.env.ONLYBOXES_BASE_URL,
+ ONLYBOXES_JIT_ISSUER: process.env.ONLYBOXES_JIT_ISSUER,
+ ONLYBOXES_JIT_SIGNING_KEY: process.env.ONLYBOXES_JIT_SIGNING_KEY,
+ ONLYBOXES_JIT_TTL_SEC: process.env.ONLYBOXES_JIT_TTL_SEC,
+ ONLYBOXES_LEASE_TTL_SEC: process.env.ONLYBOXES_LEASE_TTL_SEC,
+ SANDBOX_PROVIDER: process.env.SANDBOX_PROVIDER,
+ },
+ server: {
+ ONLYBOXES_BASE_URL: z.preprocess(emptyStringToUndefined, z.string().url().optional()),
+ ONLYBOXES_JIT_ISSUER: z.preprocess(emptyStringToUndefined, z.string().optional()),
+ ONLYBOXES_JIT_SIGNING_KEY: z.preprocess(emptyStringToUndefined, z.string().optional()),
+ ONLYBOXES_JIT_TTL_SEC: z.preprocess(
+ emptyStringToUndefined,
+ z.coerce.number().int().positive().optional(),
+ ),
+ ONLYBOXES_LEASE_TTL_SEC: z.preprocess(emptyStringToUndefined, z.coerce.number().optional()),
+ SANDBOX_PROVIDER: z.preprocess(
+ emptyStringToUndefined,
+ z.enum(['market', 'onlyboxes']).optional(),
+ ),
+ },
+ });
+};
+
+export const sandboxEnv = getSandboxConfig();
diff --git a/src/server/modules/S3/index.test.ts b/src/server/modules/S3/index.test.ts
index 6251ac24aa..dcb90a80d2 100644
--- a/src/server/modules/S3/index.test.ts
+++ b/src/server/modules/S3/index.test.ts
@@ -380,6 +380,24 @@ describe('FileS3', () => {
});
});
+ describe('createPreSignedUpload', () => {
+ it('should return upload headers required by the signed PUT request', async () => {
+ const s3 = new FileS3();
+
+ const result = await s3.createPreSignedUpload('upload-file.txt');
+
+ expect(PutObjectCommand).toHaveBeenCalledWith({
+ ACL: 'public-read',
+ Bucket: 'test-bucket',
+ Key: 'upload-file.txt',
+ });
+ expect(result).toEqual({
+ headers: { 'x-amz-acl': 'public-read' },
+ url: 'https://presigned-url.example.com',
+ });
+ });
+ });
+
describe('createPreSignedUrlForPreview', () => {
it('should create presigned URL for preview with default expiration', async () => {
const s3 = new FileS3();
diff --git a/src/server/modules/S3/index.ts b/src/server/modules/S3/index.ts
index b55679b38c..3942f69c3b 100644
--- a/src/server/modules/S3/index.ts
+++ b/src/server/modules/S3/index.ts
@@ -24,6 +24,12 @@ export const listFileSchema = z.array(fileSchema);
export type FileType = z.infer;
const DEFAULT_S3_REGION = 'us-east-1';
+const PUBLIC_READ_ACL_HEADER = 'public-read';
+
+export interface PreSignedUpload {
+ headers?: Record;
+ url: string;
+}
export class S3 {
private readonly client: S3Client;
@@ -133,13 +139,23 @@ export class S3 {
}
public async createPreSignedUrl(key: string): Promise {
+ const upload = await this.createPreSignedUpload(key);
+ return upload.url;
+ }
+
+ public async createPreSignedUpload(key: string): Promise {
const command = new PutObjectCommand({
- ACL: this.setAcl ? 'public-read' : undefined,
+ ACL: this.setAcl ? PUBLIC_READ_ACL_HEADER : undefined,
Bucket: this.bucket,
Key: key,
});
- return getSignedUrl(this.client, command, { expiresIn: 3600 });
+ const url = await getSignedUrl(this.client, command, { expiresIn: 3600 });
+
+ return {
+ headers: this.setAcl ? { 'x-amz-acl': PUBLIC_READ_ACL_HEADER } : undefined,
+ url,
+ };
}
public async createPreSignedUrlForPreview(key: string, expiresIn?: number): Promise {
diff --git a/src/server/routers/tools/market.ts b/src/server/routers/tools/market.ts
index 2015dd6791..3cff4ce095 100644
--- a/src/server/routers/tools/market.ts
+++ b/src/server/routers/tools/market.ts
@@ -1,8 +1,6 @@
import { MARKET_AUTH_REQUIRED_MESSAGE } from '@lobechat/desktop-bridge';
-import { type CodeInterpreterToolName } from '@lobehub/market-sdk';
import { TRPCError } from '@trpc/server';
import debug from 'debug';
-import { sha256 } from 'js-sha256';
import { z } from 'zod';
import { AgentSkillModel } from '@/database/models/agentSkill';
@@ -12,7 +10,6 @@ import { authedProcedure, router } from '@/libs/trpc/lambda';
import { marketUserInfo, serverDatabase, telemetry } from '@/libs/trpc/lambda/middleware';
import { marketSDK, requireMarketAuth } from '@/libs/trpc/lambda/middleware/marketSDK';
import { isTrustedClientEnabled } from '@/libs/trusted-client';
-import { FileS3 } from '@/server/modules/S3';
import { DiscoverService } from '@/server/services/discover';
import { FileService } from '@/server/services/file';
import { MarketService } from '@/server/services/market';
@@ -20,6 +17,7 @@ import {
contentBlocksToString,
processContentBlocks,
} from '@/server/services/mcp/contentProcessor';
+import { createSandboxService } from '@/server/services/sandbox';
import { scheduleToolCallReport } from './_helpers';
import {
@@ -30,6 +28,27 @@ import {
const log = debug('lobe-server:tools:market');
+const isSandboxAuthError = (error?: { message?: string; name?: string }) => {
+ const code = error?.name;
+ const message = error?.message || '';
+
+ return (
+ code === 'invalid_token' ||
+ code === 'token_expired' ||
+ code === 'unauthorized' ||
+ message.toLowerCase().includes('invalid_token') ||
+ message.toLowerCase().includes('token expired') ||
+ message.toLowerCase().includes('unauthorized')
+ );
+};
+
+const throwSandboxAuthError = () => {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: MARKET_AUTH_REQUIRED_MESSAGE,
+ });
+};
+
// ============================== Common Procedure ==============================
const marketToolProcedure = authedProcedure
.use(serverDatabase)
@@ -197,7 +216,7 @@ const execInSandboxHandler = async ({
const fullUrl = await ctx.fileService.getFullFileUrl(fileInfo.url);
if (fullUrl) {
skillZipUrls[activatedSkill.name] = fullUrl;
- log('Resolved zipUrl for skill %s: %s', activatedSkill.name, fullUrl);
+ log('Resolved zipUrl for skill %s', activatedSkill.name);
}
}
@@ -211,50 +230,22 @@ const execInSandboxHandler = async ({
}
}
- const market = ctx.marketService.market;
+ const sandboxService = createSandboxService({
+ fileService: ctx.fileService,
+ marketService: ctx.marketService,
+ topicId,
+ userId,
+ });
- const response = await market.plugins.runBuildInTool(
- toolName as CodeInterpreterToolName,
- enhancedParams as any,
- { topicId, userId },
- );
+ const response = await sandboxService.callTool(toolName, enhancedParams);
log('execInSandbox response for %s: %O', toolName, response);
- if (!response.success) {
- const errorCode = response.error?.code;
- const errorMessage = response.error?.message || 'Unknown error';
-
- // Check for authentication errors and throw UNAUTHORIZED to trigger market auth flow
- if (
- errorCode === 'invalid_token' ||
- errorCode === 'token_expired' ||
- errorCode === 'unauthorized' ||
- errorMessage.toLowerCase().includes('invalid_token') ||
- errorMessage.toLowerCase().includes('token expired')
- ) {
- throw new TRPCError({
- code: 'UNAUTHORIZED',
- message: MARKET_AUTH_REQUIRED_MESSAGE,
- });
- }
-
- return {
- error: {
- message: errorMessage,
- name: errorCode,
- },
- result: null,
- sessionExpiredAndRecreated: false,
- success: false,
- };
+ if (!response.success && isSandboxAuthError(response.error)) {
+ throwSandboxAuthError();
}
- return {
- result: response.data?.result,
- sessionExpiredAndRecreated: response.data?.sessionExpiredAndRecreated || false,
- success: true,
- };
+ return response;
} catch (error) {
log('execInSandbox error for %s: %O', toolName, error);
@@ -659,94 +650,19 @@ export const marketRouter = router({
log('Exporting and uploading file: %s from path: %s in topic: %s', filename, path, topicId);
try {
- const s3 = new FileS3();
-
- // Use date-based sharding for privacy compliance (GDPR, CCPA)
- const today = new Date().toISOString().split('T')[0];
-
- // Generate a unique key for the exported file
- const key = `code-interpreter-exports/${today}/${topicId}/${filename}`;
-
- // Step 1: Generate pre-signed upload URL
- const uploadUrl = await s3.createPreSignedUrl(key);
- log('Generated upload URL for key: %s', key);
-
- // Step 2: Use MarketService from ctx
- const market = ctx.marketService.market;
-
- // Step 3: Call sandbox's exportFile tool with the upload URL
- const response = await market.plugins.runBuildInTool(
- 'exportFile',
- { path, uploadUrl },
- { topicId, userId: ctx.userId },
- );
-
- log('Sandbox exportFile response: %O', response);
-
- if (!response.success) {
- const errorCode = response.error?.code;
- const errorMessage = response.error?.message || 'Failed to export file from sandbox';
-
- // Check for authentication errors and throw UNAUTHORIZED
- if (
- errorCode === 'invalid_token' ||
- errorCode === 'token_expired' ||
- errorCode === 'unauthorized' ||
- errorMessage.toLowerCase().includes('invalid_token') ||
- errorMessage.toLowerCase().includes('token expired')
- ) {
- throw new TRPCError({
- code: 'UNAUTHORIZED',
- message:
- 'Market authorization expired. An authorization dialog has been shown to the user. Please wait for the user to complete authorization and then retry the current task.',
- });
- }
-
- return {
- error: { message: errorMessage },
- filename,
- success: false,
- } as ExportAndUploadFileResult;
- }
-
- const result = response.data?.result;
- const uploadSuccess = result?.success !== false;
-
- if (!uploadSuccess) {
- return {
- error: { message: result?.error || 'Failed to upload file from sandbox' },
- filename,
- success: false,
- } as ExportAndUploadFileResult;
- }
-
- // Step 4: Get file metadata from S3 to verify upload and get actual size
- const metadata = await s3.getFileMetadata(key);
- const fileSize = metadata.contentLength;
- const mimeType = metadata.contentType || result?.mimeType || 'application/octet-stream';
-
- // Step 5: Create persistent file record using FileService
- // Generate a simple hash from the key (since we don't have the actual file content)
- const fileHash = sha256(key + Date.now().toString());
-
- const { fileId, url } = await ctx.fileService.createFileRecord({
- fileHash,
- fileType: mimeType,
- name: filename,
- size: fileSize,
- url: key, // Store S3 key
+ const sandboxService = createSandboxService({
+ fileService: ctx.fileService,
+ marketService: ctx.marketService,
+ topicId,
+ userId: ctx.userId,
});
+ const result = await sandboxService.exportAndUploadFile(path, filename);
- log('Created file record: fileId=%s, url=%s', fileId, url);
+ if (!result.success && isSandboxAuthError(result.error)) {
+ throwSandboxAuthError();
+ }
- return {
- fileId,
- filename,
- mimeType,
- size: fileSize,
- success: true,
- url, // This is the permanent /f/:id URL
- } as ExportAndUploadFileResult;
+ return result as ExportAndUploadFileResult;
} catch (error) {
log('Error in exportAndUploadFile: %O', error);
@@ -763,11 +679,7 @@ export const marketRouter = router({
errorMessage.toLowerCase().includes('token expired') ||
errorMessage.toLowerCase().includes('unauthorized')
) {
- throw new TRPCError({
- code: 'UNAUTHORIZED',
- message:
- 'Market authorization expired. An authorization dialog has been shown to the user. Please wait for the user to complete authorization and then retry the current task.',
- });
+ throwSandboxAuthError();
}
return {
diff --git a/src/server/services/file/__tests__/index.test.ts b/src/server/services/file/__tests__/index.test.ts
index 8b43771f13..2118a5278c 100644
--- a/src/server/services/file/__tests__/index.test.ts
+++ b/src/server/services/file/__tests__/index.test.ts
@@ -25,6 +25,7 @@ vi.mock('../impls', () => ({
getFileContent: vi.fn(),
getFileByteArray: vi.fn(),
getFileMetadata: vi.fn(),
+ createPreSignedUpload: vi.fn(),
createPreSignedUrl: vi.fn(),
createPreSignedUrlForPreview: vi.fn(),
createCachedPreSignedUrlForPreview: vi.fn(),
@@ -207,6 +208,20 @@ describe('FileService', () => {
expect(result).toBe(expectedUrl);
});
+ it('should delegate createPreSignedUpload to implementation', async () => {
+ const testKey = 'test-key';
+ const expectedUpload = {
+ headers: { 'x-amz-acl': 'public-read' },
+ url: 'https://example.com/signed-url',
+ };
+ vi.mocked(service['impl'].createPreSignedUpload).mockResolvedValue(expectedUpload);
+
+ const result = await service.createPreSignedUpload(testKey);
+
+ expect(service['impl'].createPreSignedUpload).toHaveBeenCalledWith(testKey);
+ expect(result).toBe(expectedUpload);
+ });
+
it('should delegate createPreSignedUrlForPreview to implementation', async () => {
const testKey = 'test-key';
const expiresIn = 3600;
diff --git a/src/server/services/file/impls/s3.test.ts b/src/server/services/file/impls/s3.test.ts
index 598a4d1b9b..6c9b31ddc2 100644
--- a/src/server/services/file/impls/s3.test.ts
+++ b/src/server/services/file/impls/s3.test.ts
@@ -49,6 +49,10 @@ vi.mock('@/server/modules/S3', () => ({
getFileMetadata: vi.fn().mockResolvedValue({ contentLength: 1024, contentType: 'image/png' }),
deleteFile: vi.fn().mockResolvedValue({}),
deleteFiles: vi.fn().mockResolvedValue({}),
+ createPreSignedUpload: vi.fn().mockResolvedValue({
+ headers: { 'x-amz-acl': 'public-read' },
+ url: 'https://upload.example.com/test.jpg',
+ }),
createPreSignedUrl: vi.fn().mockResolvedValue('https://upload.example.com/test.jpg'),
uploadContent: vi.fn().mockResolvedValue({}),
uploadMedia: vi.fn().mockResolvedValue({}),
@@ -281,6 +285,18 @@ describe('S3StaticFileImpl', () => {
});
});
+ describe('createPreSignedUpload', () => {
+ it('should call S3 createPreSignedUpload and return upload headers', async () => {
+ const result = await fileService.createPreSignedUpload('test.jpg');
+
+ expect(fileService['s3'].createPreSignedUpload).toHaveBeenCalledWith('test.jpg');
+ expect(result).toEqual({
+ headers: { 'x-amz-acl': 'public-read' },
+ url: 'https://upload.example.com/test.jpg',
+ });
+ });
+ });
+
describe('getFileMetadata', () => {
it('should call S3 getFileMetadata and return metadata', async () => {
const result = await fileService.getFileMetadata('test.png');
diff --git a/src/server/services/file/impls/s3.ts b/src/server/services/file/impls/s3.ts
index affd6a805c..ac9c0a914e 100644
--- a/src/server/services/file/impls/s3.ts
+++ b/src/server/services/file/impls/s3.ts
@@ -8,7 +8,7 @@ import { getRedisConfig } from '@/envs/redis';
import { initializeRedis, isRedisEnabled } from '@/libs/redis';
import { FileS3 } from '@/server/modules/S3';
-import { type FileServiceImpl } from './type';
+import type { FileServiceImpl, PreSignedUpload } from './type';
const log = debug('lobe-file:s3');
@@ -64,6 +64,10 @@ export class S3StaticFileImpl implements FileServiceImpl {
return this.s3.createPreSignedUrl(key);
}
+ async createPreSignedUpload(key: string): Promise {
+ return this.s3.createPreSignedUpload(key);
+ }
+
async getFileMetadata(key: string): Promise<{ contentLength: number; contentType?: string }> {
return this.s3.getFileMetadata(key);
}
diff --git a/src/server/services/file/impls/type.ts b/src/server/services/file/impls/type.ts
index 2ae449eb70..e513969412 100644
--- a/src/server/services/file/impls/type.ts
+++ b/src/server/services/file/impls/type.ts
@@ -1,3 +1,7 @@
+import type { PreSignedUpload } from '@/server/modules/S3';
+
+export type { PreSignedUpload };
+
/**
* File service implementation interface
*/
@@ -6,6 +10,12 @@ export interface FileServiceImpl {
* Create cached pre-signed preview URL
*/
createCachedPreSignedUrlForPreview: (url?: string | null, expiresIn?: number) => Promise;
+
+ /**
+ * Create pre-signed upload descriptor
+ */
+ createPreSignedUpload: (key: string) => Promise;
+
/**
* Create pre-signed upload URL
*/
diff --git a/src/server/services/file/index.ts b/src/server/services/file/index.ts
index b9bc1bec0e..a99d3a3ace 100644
--- a/src/server/services/file/index.ts
+++ b/src/server/services/file/index.ts
@@ -11,7 +11,7 @@ import { TempFileManager } from '@/server/utils/tempFileManager';
import { isDev } from '@/utils/env';
import { createFileServiceModule } from './impls';
-import { type FileServiceImpl } from './impls/type';
+import type { FileServiceImpl, PreSignedUpload } from './impls/type';
export const getFileProxyUrl = (fileId: string): string => `${appEnv.APP_URL}/f/${fileId}`;
@@ -72,6 +72,13 @@ export class FileService {
return this.impl.createPreSignedUrl(key);
}
+ /**
+ * Create pre-signed upload descriptor
+ */
+ public async createPreSignedUpload(key: string): Promise {
+ return this.impl.createPreSignedUpload(key);
+ }
+
/**
* Get file metadata from storage
* Used to verify actual file size instead of trusting client-provided values
diff --git a/src/server/services/heterogeneousAgent/sandboxRunner.ts b/src/server/services/heterogeneousAgent/sandboxRunner.ts
index 06ed819bd0..b1dfbfa4aa 100644
--- a/src/server/services/heterogeneousAgent/sandboxRunner.ts
+++ b/src/server/services/heterogeneousAgent/sandboxRunner.ts
@@ -2,6 +2,7 @@ import debug from 'debug';
import { appEnv } from '@/envs/app';
import type { MarketService } from '@/server/services/market';
+import { createSandboxService } from '@/server/services/sandbox';
const log = debug('lobe-server:hetero-sandbox-runner');
@@ -96,8 +97,8 @@ function buildRepoSetupScript(repos: string[], githubToken?: string): string | n
/**
* Launches `lh hetero exec` inside the cloud sandbox via `runCommand`.
*
- * Uses the same MarketService path as ServerSandboxService.callTool —
- * `marketService.getSDK().plugins.runBuildInTool('runCommand', params, ctx)`.
+ * Uses the configured sandbox provider so cloud, third-party, and self-hosted
+ * sandboxes share the same launch path.
*
* The sandbox container already has `lh` (the LobeHub CLI) installed.
* The operation-scoped JWT is injected as `LOBEHUB_JWT` so the CLI can
@@ -192,7 +193,14 @@ export async function spawnHeteroSandbox(params: SandboxRunParams): Promise {
+ return this.serviceResult;
+ }
+}
+
+describe('ComputerRuntime command status mapping', () => {
+ it('uses command result success when command transport succeeds with non-zero exit code', async () => {
+ const runtime = new TestComputerRuntime({
+ result: {
+ exitCode: 2,
+ stderr: 'failed',
+ stdout: 'partial',
+ success: false,
+ },
+ success: true,
+ });
+
+ const result = await runtime.runCommand({ command: 'exit 2' });
+
+ expect(result).toMatchObject({
+ state: {
+ exitCode: 2,
+ stderr: 'failed',
+ stdout: 'partial',
+ success: false,
+ },
+ success: true,
+ });
+ });
+
+ it('uses command output result success when background task transport succeeds', async () => {
+ const runtime = new TestComputerRuntime({
+ result: {
+ output: 'failed',
+ running: false,
+ success: false,
+ },
+ success: true,
+ });
+
+ const result = await runtime.getCommandOutput({ commandId: 'task-1' });
+
+ expect(result).toMatchObject({
+ state: {
+ newOutput: 'failed',
+ running: false,
+ success: false,
+ },
+ success: true,
+ });
+ });
+});
diff --git a/src/server/services/sandbox/__tests__/factory.test.ts b/src/server/services/sandbox/__tests__/factory.test.ts
new file mode 100644
index 0000000000..ad0c0cc1bb
--- /dev/null
+++ b/src/server/services/sandbox/__tests__/factory.test.ts
@@ -0,0 +1,55 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { MarketService } from '@/server/services/market';
+
+const baseOptions = {
+ marketService: {} as MarketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+};
+
+describe('sandbox service factory', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ it('uses the market provider by default', async () => {
+ vi.doMock('@/envs/sandbox', () => ({
+ sandboxEnv: {},
+ }));
+
+ const { createSandboxService } = await import('../factory');
+ const service = createSandboxService(baseOptions);
+
+ expect(service.kind).toBe('market');
+ expect(service.capabilities).toMatchObject({
+ backgroundCommands: true,
+ exportFile: true,
+ files: true,
+ persistentSession: true,
+ shell: true,
+ skillScripts: true,
+ });
+ });
+
+ it('uses the onlyboxes provider when configured', async () => {
+ vi.doMock('@/envs/app', () => ({
+ appEnv: {
+ APP_URL: 'https://lobehub.example.com',
+ },
+ }));
+ vi.doMock('@/envs/sandbox', () => ({
+ sandboxEnv: {
+ ONLYBOXES_BASE_URL: 'https://onlyboxes.example.com',
+ ONLYBOXES_JIT_SIGNING_KEY: 'jit-signing-key',
+ SANDBOX_PROVIDER: 'onlyboxes',
+ },
+ }));
+
+ const { createSandboxService } = await import('../factory');
+ const service = createSandboxService(baseOptions);
+
+ expect(service.kind).toBe('onlyboxes');
+ expect(service.capabilities.languages).toEqual(['python', 'javascript', 'typescript']);
+ });
+});
diff --git a/src/server/services/sandbox/__tests__/service.test.ts b/src/server/services/sandbox/__tests__/service.test.ts
new file mode 100644
index 0000000000..aefdbb02cd
--- /dev/null
+++ b/src/server/services/sandbox/__tests__/service.test.ts
@@ -0,0 +1,125 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { FileService } from '@/server/services/file';
+import type { MarketService } from '@/server/services/market';
+
+import type { SandboxProvider } from '../types';
+
+describe('SandboxMiddlewareService', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.restoreAllMocks();
+ });
+
+ it('uploads provider exports through the shared file record flow', async () => {
+ const { SandboxMiddlewareService: TestSandboxMiddlewareService } = await import('../service');
+ const exportFileToUploadUrl = vi.fn(async () => ({
+ result: { mime_type: 'text/plain' },
+ success: true,
+ }));
+ const provider = {
+ capabilities: {
+ backgroundCommands: true,
+ exportFile: true,
+ files: true,
+ languages: ['python'],
+ persistentSession: true,
+ shell: true,
+ skillScripts: true,
+ },
+ callTool: vi.fn(),
+ exportFileToUploadUrl,
+ kind: 'onlyboxes',
+ } satisfies SandboxProvider;
+
+ const fileService = {
+ createPreSignedUpload: vi.fn(async () => ({
+ headers: { 'x-amz-acl': 'public-read' },
+ url: 'https://uploads.example.com/put',
+ })),
+ createFileRecord: vi.fn(async () => ({ fileId: 'file-1', url: '/f/file-1' })),
+ getFileMetadata: vi.fn(async () => ({
+ contentLength: 42,
+ contentType: 'text/csv',
+ })),
+ } as unknown as FileService;
+
+ const service = new TestSandboxMiddlewareService(provider, {
+ fileService,
+ marketService: {} as MarketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await service.exportAndUploadFile('/workspace/result.csv', 'result.csv');
+
+ expect(result).toMatchObject({
+ fileId: 'file-1',
+ filename: 'result.csv',
+ mimeType: 'text/csv',
+ size: 42,
+ success: true,
+ url: '/f/file-1',
+ });
+ expect(exportFileToUploadUrl).toHaveBeenCalledWith({
+ filename: 'result.csv',
+ path: '/workspace/result.csv',
+ uploadHeaders: { 'x-amz-acl': 'public-read' },
+ uploadUrl: 'https://uploads.example.com/put',
+ });
+ expect(fileService.createFileRecord).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fileType: 'text/csv',
+ name: 'result.csv',
+ size: 42,
+ url: expect.stringMatching(
+ /^code-interpreter-exports\/\d{4}-\d{2}-\d{2}\/topic-1\/result\.csv$/,
+ ),
+ }),
+ );
+ });
+
+ it('normalizes provider export failures before storage metadata is read', async () => {
+ const provider = {
+ capabilities: {
+ backgroundCommands: true,
+ exportFile: true,
+ files: true,
+ languages: ['python'],
+ persistentSession: true,
+ shell: true,
+ skillScripts: true,
+ },
+ callTool: vi.fn(),
+ exportFileToUploadUrl: vi.fn(async () => ({
+ error: { message: 'no such file', name: 'not_found' },
+ success: false,
+ })),
+ kind: 'onlyboxes',
+ } satisfies SandboxProvider;
+
+ const { SandboxMiddlewareService } = await import('../service');
+ const fileService = {
+ createFileRecord: vi.fn(),
+ createPreSignedUpload: vi.fn(async () => ({ url: 'https://uploads.example.com/put' })),
+ getFileMetadata: vi.fn(),
+ } as unknown as FileService;
+
+ const service = new SandboxMiddlewareService(provider, {
+ fileService,
+ marketService: {} as MarketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await service.exportAndUploadFile('/workspace/missing.txt', 'missing.txt');
+
+ expect(result).toMatchObject({
+ error: { message: 'no such file', name: 'not_found' },
+ filename: 'missing.txt',
+ success: false,
+ });
+ expect(fileService.getFileMetadata).not.toHaveBeenCalled();
+ expect(fileService.createFileRecord).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/server/services/sandbox/factory.ts b/src/server/services/sandbox/factory.ts
new file mode 100644
index 0000000000..d735e9f068
--- /dev/null
+++ b/src/server/services/sandbox/factory.ts
@@ -0,0 +1,31 @@
+import { sandboxEnv } from '@/envs/sandbox';
+
+import { MarketSandboxProvider } from './providers/market';
+import { OnlyboxesSandboxProvider } from './providers/onlyboxes';
+import { SandboxMiddlewareService } from './service';
+import type {
+ SandboxProvider,
+ SandboxProviderKind,
+ SandboxService,
+ SandboxServiceOptions,
+} from './types';
+
+export const getSandboxProviderKind = (): SandboxProviderKind => {
+ return sandboxEnv.SANDBOX_PROVIDER || 'market';
+};
+
+const createSandboxProvider = (options: SandboxServiceOptions): SandboxProvider => {
+ switch (getSandboxProviderKind()) {
+ case 'onlyboxes': {
+ return new OnlyboxesSandboxProvider(options);
+ }
+
+ case 'market': {
+ return new MarketSandboxProvider(options);
+ }
+ }
+};
+
+export const createSandboxService = (options: SandboxServiceOptions): SandboxService => {
+ return new SandboxMiddlewareService(createSandboxProvider(options), options);
+};
diff --git a/src/server/services/sandbox/index.ts b/src/server/services/sandbox/index.ts
index ea6d2177f0..9031b0440e 100644
--- a/src/server/services/sandbox/index.ts
+++ b/src/server/services/sandbox/index.ts
@@ -1,186 +1,12 @@
-import {
- type ISandboxService,
- type SandboxCallToolResult,
- type SandboxExportFileResult,
-} from '@lobechat/builtin-tool-cloud-sandbox';
-import { type CodeInterpreterToolName } from '@lobehub/market-sdk';
-import debug from 'debug';
-import { sha256 } from 'js-sha256';
-
-import { FileS3 } from '@/server/modules/S3';
-import { type FileService } from '@/server/services/file';
-import { type MarketService } from '@/server/services/market';
-
-const log = debug('lobe-server:sandbox-service');
-
-export interface ServerSandboxServiceOptions {
- fileService: FileService;
- marketService: MarketService;
- topicId: string;
- userId: string;
-}
-
-/**
- * Server-side Sandbox Service
- *
- * This service implements ISandboxService for server-side execution.
- * Context (topicId, userId) is bound at construction time.
- * It uses MarketService to call sandbox tools.
- *
- * Usage:
- * - Used by BuiltinToolsExecutor when executing CloudSandbox tools on server
- * - MarketService handles authentication via trustedClientToken
- */
-export class ServerSandboxService implements ISandboxService {
- private fileService: FileService;
- private marketService: MarketService;
- private topicId: string;
- private userId: string;
-
- constructor(options: ServerSandboxServiceOptions) {
- this.fileService = options.fileService;
- this.marketService = options.marketService;
- this.topicId = options.topicId;
- this.userId = options.userId;
- }
-
- /**
- * Call a sandbox tool via MarketService
- */
- async callTool(toolName: string, params: Record): Promise {
- log('Calling sandbox tool: %s with params: %O, topicId: %s', toolName, params, this.topicId);
-
- try {
- const response = await this.marketService
- .getSDK()
- .plugins.runBuildInTool(toolName as CodeInterpreterToolName, params as any, {
- topicId: this.topicId,
- userId: this.userId,
- });
-
- log('Sandbox tool %s response: %O', toolName, response);
-
- if (!response.success) {
- return {
- error: {
- message: response.error?.message || 'Unknown error',
- name: response.error?.code,
- },
- result: null,
- sessionExpiredAndRecreated: false,
- success: false,
- };
- }
-
- return {
- result: response.data?.result,
- sessionExpiredAndRecreated: response.data?.sessionExpiredAndRecreated || false,
- success: true,
- };
- } catch (error) {
- log('Error calling sandbox tool %s: %O', toolName, error);
-
- return {
- error: {
- message: (error as Error).message,
- name: (error as Error).name,
- },
- result: null,
- sessionExpiredAndRecreated: false,
- success: false,
- };
- }
- }
-
- /**
- * Export and upload a file from sandbox to S3
- *
- * Steps:
- * 1. Generate S3 pre-signed upload URL
- * 2. Call sandbox exportFile tool to upload file
- * 3. Verify upload success and get metadata
- * 4. Create persistent file record
- */
- async exportAndUploadFile(path: string, filename: string): Promise {
- log('Exporting file: %s from path: %s, topicId: %s', filename, path, this.topicId);
-
- try {
- const s3 = new FileS3();
-
- // Use date-based sharding for privacy compliance (GDPR, CCPA)
- const today = new Date().toISOString().split('T')[0];
-
- // Generate a unique key for the exported file
- const key = `code-interpreter-exports/${today}/${this.topicId}/${filename}`;
-
- // Step 1: Generate pre-signed upload URL
- const uploadUrl = await s3.createPreSignedUrl(key);
- log('Generated upload URL for key: %s', key);
-
- // Step 2: Call sandbox's exportFile tool with the upload URL
- const response = await this.marketService.exportFile({
- path,
- topicId: this.topicId,
- uploadUrl,
- userId: this.userId,
- });
-
- log('Sandbox exportFile response: %O', response);
-
- if (!response.success) {
- return {
- error: { message: response.error?.message || 'Failed to export file from sandbox' },
- filename,
- success: false,
- };
- }
-
- const result = response.data?.result;
- const uploadSuccess = result?.success !== false;
-
- if (!uploadSuccess) {
- return {
- error: { message: result?.error || 'Failed to upload file from sandbox' },
- filename,
- success: false,
- };
- }
-
- // Step 3: Get file metadata from S3 to verify upload and get actual size
- const metadata = await s3.getFileMetadata(key);
- const fileSize = metadata.contentLength;
- const mimeType = metadata.contentType || result?.mimeType || 'application/octet-stream';
-
- // Step 4: Create persistent file record using FileService
- // Generate a simple hash from the key (since we don't have the actual file content)
- const fileHash = sha256(key + Date.now().toString());
-
- const { fileId, url } = await this.fileService.createFileRecord({
- fileHash,
- fileType: mimeType,
- name: filename,
- size: fileSize,
- url: key, // Store S3 key
- });
-
- log('Created file record: fileId=%s, url=%s', fileId, url);
-
- return {
- fileId,
- filename,
- mimeType,
- size: fileSize,
- success: true,
- url, // This is the permanent /f/:id URL
- };
- } catch (error) {
- log('Error exporting file: %O', error);
-
- return {
- error: { message: (error as Error).message },
- filename,
- success: false,
- };
- }
- }
-}
+export { createSandboxService, getSandboxProviderKind } from './factory';
+export { MarketSandboxProvider, ServerSandboxService } from './providers/market';
+export { OnlyboxesSandboxProvider } from './providers/onlyboxes';
+export { normalizeSandboxCommandResult, SandboxMiddlewareService } from './service';
+export type {
+ SandboxFileExporter,
+ SandboxProvider,
+ SandboxProviderKind,
+ SandboxService,
+ SandboxServiceOptions,
+ SandboxSessionContext,
+} from './types';
diff --git a/src/server/services/sandbox/providers/market.test.ts b/src/server/services/sandbox/providers/market.test.ts
new file mode 100644
index 0000000000..a8caebfa64
--- /dev/null
+++ b/src/server/services/sandbox/providers/market.test.ts
@@ -0,0 +1,192 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import type { MarketService } from '@/server/services/market';
+
+import { MarketSandboxProvider, redactSandboxParams } from './market';
+
+describe('MarketSandboxProvider', () => {
+ const createMarketService = (response: unknown) =>
+ ({
+ exportFile: vi.fn(async () => response),
+ getSDK: vi.fn(() => ({
+ plugins: {
+ runBuildInTool: vi.fn(async () => response),
+ },
+ })),
+ }) as unknown as MarketService;
+
+ it('keeps the previous Market sandbox callTool success response shape', async () => {
+ const marketService = createMarketService({
+ data: {
+ result: {
+ exitCode: 0,
+ stdout: 'ok',
+ },
+ sessionExpiredAndRecreated: true,
+ },
+ success: true,
+ });
+ const provider = new MarketSandboxProvider({
+ marketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await provider.callTool('runCommand', { command: 'echo ok' });
+
+ expect(result).toEqual({
+ result: {
+ exitCode: 0,
+ stdout: 'ok',
+ },
+ sessionExpiredAndRecreated: true,
+ success: true,
+ });
+ });
+
+ it('keeps the previous Market sandbox callTool error mapping', async () => {
+ const marketService = createMarketService({
+ error: {
+ code: 'token_expired',
+ message: 'expired',
+ },
+ success: false,
+ });
+ const provider = new MarketSandboxProvider({
+ marketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await provider.callTool('runCommand', { command: 'echo ok' });
+
+ expect(result).toEqual({
+ error: {
+ message: 'expired',
+ name: 'token_expired',
+ },
+ result: null,
+ sessionExpiredAndRecreated: false,
+ success: false,
+ });
+ });
+
+ it('preserves Market sandbox export error codes for authorization handling', async () => {
+ const marketService = createMarketService({
+ error: {
+ code: 'token_expired',
+ message: 'expired',
+ },
+ success: false,
+ });
+ const provider = new MarketSandboxProvider({
+ marketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await provider.exportFileToUploadUrl({
+ filename: 'report.txt',
+ path: '/workspace/report.txt',
+ uploadUrl: 'https://uploads.example.com/put',
+ });
+
+ expect(result).toEqual({
+ error: {
+ message: 'expired',
+ name: 'token_expired',
+ },
+ success: false,
+ });
+ });
+
+ it('keeps the previous Market sandbox export success response shape', async () => {
+ const marketService = createMarketService({
+ data: {
+ result: {
+ mimeType: 'text/plain',
+ success: true,
+ },
+ },
+ success: true,
+ });
+ const provider = new MarketSandboxProvider({
+ marketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await provider.exportFileToUploadUrl({
+ filename: 'report.txt',
+ path: '/workspace/report.txt',
+ uploadUrl: 'https://uploads.example.com/put',
+ });
+
+ expect(result).toEqual({
+ mimeType: 'text/plain',
+ result: {
+ mimeType: 'text/plain',
+ success: true,
+ },
+ success: true,
+ });
+ });
+
+ it('keeps the previous Market sandbox upload failure mapping', async () => {
+ const marketService = createMarketService({
+ data: {
+ result: {
+ error: 'upload failed',
+ success: false,
+ },
+ },
+ success: true,
+ });
+ const provider = new MarketSandboxProvider({
+ marketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await provider.exportFileToUploadUrl({
+ filename: 'report.txt',
+ path: '/workspace/report.txt',
+ uploadUrl: 'https://uploads.example.com/put',
+ });
+
+ expect(result).toEqual({
+ error: {
+ message: 'upload failed',
+ },
+ success: false,
+ });
+ });
+
+ describe('redactSandboxParams', () => {
+ it('redacts auth env assignments from command logs without changing other params', () => {
+ const params = {
+ command:
+ 'LOBEHUB_JWT=mock-jwt LOBEHUB_SERVER=https://app.lobehub.com npx -y @lobehub/cli topic list && GITHUB_TOKEN="ghp_token" gh repo view',
+ timeout: 1000,
+ };
+
+ expect(redactSandboxParams(params)).toEqual({
+ command:
+ 'LOBEHUB_JWT=[redacted] LOBEHUB_SERVER=https://app.lobehub.com npx -y @lobehub/cli topic list && GITHUB_TOKEN=[redacted] gh repo view',
+ timeout: 1000,
+ });
+ });
+
+ it('redacts sandbox resource URLs from params', () => {
+ expect(
+ redactSandboxParams({
+ skillZipUrls: { chart: 'https://files.example.com/chart.zip' },
+ zipUrl: 'https://files.example.com/legacy.zip',
+ }),
+ ).toEqual({
+ skillZipUrls: '[redacted]',
+ zipUrl: '[redacted]',
+ });
+ });
+ });
+});
diff --git a/src/server/services/sandbox/providers/market.ts b/src/server/services/sandbox/providers/market.ts
new file mode 100644
index 0000000000..78a4395399
--- /dev/null
+++ b/src/server/services/sandbox/providers/market.ts
@@ -0,0 +1,170 @@
+import type { SandboxCallToolResult } from '@lobechat/builtin-tool-cloud-sandbox';
+import type { CodeInterpreterToolName } from '@lobehub/market-sdk';
+import debug from 'debug';
+
+import { SandboxMiddlewareService } from '../service';
+import type {
+ SandboxProvider,
+ SandboxProviderCapabilities,
+ SandboxProviderFileExportRequest,
+ SandboxProviderFileExportResult,
+ SandboxService,
+ SandboxServiceOptions,
+} from '../types';
+
+const log = debug('lobe-server:sandbox:market');
+const REDACTED_SANDBOX_PARAM = '[redacted]';
+const SANDBOX_AUTH_ENV_PATTERN = /\b(LOBEHUB_JWT|GITHUB_TOKEN)=("[^"]*"|'[^']*'|\S+)/g;
+
+export class MarketSandboxProvider implements SandboxProvider {
+ readonly capabilities = {
+ backgroundCommands: true,
+ exportFile: true,
+ files: true,
+ languages: ['python', 'javascript', 'typescript'],
+ persistentSession: true,
+ shell: true,
+ skillScripts: true,
+ } as const satisfies SandboxProviderCapabilities;
+
+ readonly kind = 'market';
+
+ private readonly options: SandboxServiceOptions;
+
+ constructor(options: SandboxServiceOptions) {
+ this.options = options;
+ }
+
+ async callTool(
+ toolName: string,
+ params: Record,
+ ): Promise {
+ const { marketService, topicId, userId } = this.options;
+
+ log(
+ 'Calling sandbox tool: %s with params: %O, topicId: %s',
+ toolName,
+ redactSandboxParams(params),
+ topicId,
+ );
+
+ try {
+ const response = await marketService
+ .getSDK()
+ .plugins.runBuildInTool(toolName as CodeInterpreterToolName, params as never, {
+ topicId,
+ userId,
+ });
+
+ log('Sandbox tool %s response: %O', toolName, response);
+
+ if (!response.success) {
+ return {
+ error: {
+ message: response.error?.message || 'Unknown error',
+ name: response.error?.code,
+ },
+ result: null,
+ sessionExpiredAndRecreated: false,
+ success: false,
+ };
+ }
+
+ return {
+ result: response.data?.result,
+ sessionExpiredAndRecreated: response.data?.sessionExpiredAndRecreated || false,
+ success: true,
+ };
+ } catch (error) {
+ log('Error calling sandbox tool %s: %O', toolName, error);
+
+ return {
+ error: {
+ message: (error as Error).message,
+ name: (error as Error).name,
+ },
+ result: null,
+ sessionExpiredAndRecreated: false,
+ success: false,
+ };
+ }
+ }
+
+ async exportFileToUploadUrl({
+ path,
+ uploadUrl,
+ }: SandboxProviderFileExportRequest): Promise {
+ const { marketService, topicId, userId } = this.options;
+
+ try {
+ const response = await marketService.exportFile({
+ path,
+ topicId,
+ uploadUrl,
+ userId,
+ });
+
+ log('Sandbox exportFile response: %O', response);
+
+ if (!response.success) {
+ return {
+ error: {
+ message: response.error?.message || 'Failed to export file from sandbox',
+ name: response.error?.code,
+ },
+ success: false,
+ };
+ }
+
+ const result = response.data?.result;
+ const uploadSuccess = result?.success !== false;
+
+ if (!uploadSuccess) {
+ return {
+ error: { message: result?.error || 'Failed to upload file from sandbox' },
+ success: false,
+ };
+ }
+
+ return {
+ mimeType: result?.mimeType,
+ result,
+ success: true,
+ };
+ } catch (error) {
+ log('Error exporting file: %O', error);
+
+ return {
+ error: { message: (error as Error).message },
+ success: false,
+ };
+ }
+ }
+}
+
+export const redactSandboxParams = (params: Record) => {
+ const hasCommand = typeof params.command === 'string';
+ if (!params.skillZipUrls && !params.zipUrl && !hasCommand) return params;
+
+ const redacted = {
+ ...params,
+ };
+
+ if (params.zipUrl) redacted.zipUrl = REDACTED_SANDBOX_PARAM;
+ if (params.skillZipUrls) redacted.skillZipUrls = REDACTED_SANDBOX_PARAM;
+ if (typeof params.command === 'string') {
+ redacted.command = params.command.replaceAll(
+ SANDBOX_AUTH_ENV_PATTERN,
+ (_, name: string) => `${name}=${REDACTED_SANDBOX_PARAM}`,
+ );
+ }
+
+ return redacted;
+};
+
+/** @deprecated Use createSandboxService. */
+export class ServerSandboxService extends SandboxMiddlewareService implements SandboxService {
+ constructor(options: SandboxServiceOptions) {
+ super(new MarketSandboxProvider(options), options);
+ }
+}
diff --git a/src/server/services/sandbox/providers/onlyboxes.test.ts b/src/server/services/sandbox/providers/onlyboxes.test.ts
new file mode 100644
index 0000000000..3983f1df1a
--- /dev/null
+++ b/src/server/services/sandbox/providers/onlyboxes.test.ts
@@ -0,0 +1,554 @@
+import { createHmac } from 'node:crypto';
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { MarketService } from '@/server/services/market';
+
+const decodeJITPayload = (authorization?: string) => {
+ const token = authorization?.replace('Bearer ', '') || '';
+ const [payload] = token.replace('obx_jit_v1.', '').split('.');
+
+ return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
+ exp: number;
+ iss: string;
+ sub: string;
+ };
+};
+
+const verifyJITSignature = (authorization?: string) => {
+ const token = authorization?.replace('Bearer ', '') || '';
+ const [signed, signature] = token.split(/\.(?=[^.]+$)/);
+ const expected = createHmac('sha256', 'jit-signing-key').update(signed).digest('base64url');
+
+ return signature === expected;
+};
+
+describe('OnlyboxesSandboxProvider', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.restoreAllMocks();
+ vi.setSystemTime(new Date('2026-05-30T00:00:00.000Z'));
+ vi.doMock('@/envs/app', () => ({
+ appEnv: {
+ APP_URL: 'https://lobehub.example.com',
+ },
+ }));
+ vi.doMock('@/envs/sandbox', () => ({
+ sandboxEnv: {
+ ONLYBOXES_BASE_URL: 'https://onlyboxes.example.com/',
+ ONLYBOXES_JIT_SIGNING_KEY: 'jit-signing-key',
+ ONLYBOXES_JIT_TTL_SEC: 900,
+ ONLYBOXES_LEASE_TTL_SEC: 120,
+ },
+ }));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('maps runCommand to the terminal command endpoint with a persistent session', async () => {
+ const fetchMock = vi.fn(async () => {
+ return new Response(
+ JSON.stringify({
+ exit_code: 0,
+ session_id: 'lobe-user-1-topic-1',
+ stderr: '',
+ stdout: 'ok\n',
+ }),
+ { status: 200 },
+ );
+ });
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { OnlyboxesSandboxProvider } = await import('./onlyboxes');
+ const provider = new OnlyboxesSandboxProvider({
+ marketService: {} as MarketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await provider.callTool('runCommand', { command: 'echo ok' });
+
+ expect(result).toMatchObject({
+ result: { exitCode: 0, stdout: 'ok\n' },
+ success: true,
+ });
+ expect(fetchMock).toHaveBeenCalledWith(
+ 'https://onlyboxes.example.com/api/v1/commands/terminal',
+ expect.objectContaining({
+ body: JSON.stringify({
+ command: 'echo ok',
+ create_if_missing: true,
+ lease_ttl_sec: 120,
+ session_id: 'lobe-user-1-topic-1',
+ timeout_ms: 120_000,
+ }),
+ method: 'POST',
+ }),
+ );
+ const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
+ const headers = init.headers as Headers;
+ const authorization = headers.get('Authorization') || undefined;
+ expect(authorization).toMatch(/^Bearer obx_jit_v1\./);
+ expect(verifyJITSignature(authorization)).toBe(true);
+ expect(decodeJITPayload(authorization)).toEqual({
+ exp: Date.parse('2026-05-30T00:15:00.000Z'),
+ iss: 'https://lobehub.example.com',
+ sub: 'user-1',
+ });
+ });
+
+ it('treats non-zero terminal exit codes as successful tool transport results', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ return new Response(
+ JSON.stringify({
+ exit_code: 2,
+ session_id: 'lobe-user-1-topic-1',
+ stderr: 'failed\n',
+ stdout: 'partial\n',
+ }),
+ { status: 200 },
+ );
+ }),
+ );
+
+ const { OnlyboxesSandboxProvider } = await import('./onlyboxes');
+ const provider = new OnlyboxesSandboxProvider({
+ marketService: {} as MarketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await provider.callTool('runCommand', { command: 'exit 2' });
+
+ expect(result).toMatchObject({
+ result: {
+ exitCode: 2,
+ stderr: 'failed\n',
+ stdout: 'partial\n',
+ success: false,
+ },
+ success: true,
+ });
+ });
+
+ it('returns a provider error when background command submission fails', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ return new Response(
+ JSON.stringify({
+ error: {
+ code: 'no_worker',
+ message: 'no compatible worker',
+ },
+ status: 'failed',
+ }),
+ { status: 200 },
+ );
+ }),
+ );
+
+ const { OnlyboxesSandboxProvider } = await import('./onlyboxes');
+ const provider = new OnlyboxesSandboxProvider({
+ marketService: {} as MarketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await provider.callTool('runCommand', {
+ background: true,
+ command: 'sleep 10',
+ });
+
+ expect(result).toMatchObject({
+ error: { message: 'no compatible worker' },
+ result: null,
+ success: false,
+ });
+ });
+
+ it('treats running background command polls as successful output retrievals', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ return new Response(
+ JSON.stringify({
+ result: {
+ stdout: 'partial\n',
+ },
+ status: 'running',
+ task_id: 'task-1',
+ }),
+ { status: 200 },
+ );
+ }),
+ );
+
+ const { OnlyboxesSandboxProvider } = await import('./onlyboxes');
+ const provider = new OnlyboxesSandboxProvider({
+ marketService: {} as MarketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await provider.callTool('getCommandOutput', { commandId: 'task-1' });
+
+ expect(result).toMatchObject({
+ result: {
+ newOutput: 'partial\n',
+ running: true,
+ success: true,
+ },
+ success: true,
+ });
+ });
+
+ it('unwraps JSON output from terminal-backed file operations', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ return new Response(
+ JSON.stringify({
+ exit_code: 0,
+ session_id: 'lobe-user-1-topic-1',
+ stderr: '',
+ stdout: JSON.stringify({
+ files: [{ isDirectory: false, name: 'a.txt' }],
+ totalCount: 1,
+ }),
+ }),
+ { status: 200 },
+ );
+ }),
+ );
+
+ const { OnlyboxesSandboxProvider } = await import('./onlyboxes');
+ const provider = new OnlyboxesSandboxProvider({
+ marketService: {} as MarketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await provider.callTool('listLocalFiles', { directoryPath: '/workspace' });
+
+ expect(result).toMatchObject({
+ result: {
+ files: [{ isDirectory: false, name: 'a.txt' }],
+ totalCount: 1,
+ },
+ success: true,
+ });
+ });
+
+ it('writes files through chunked terminal scripts instead of embedding content in one command', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ exit_code: 0,
+ session_id: 'lobe-user-1-topic-1',
+ stderr: '',
+ stdout: JSON.stringify({ success: true }),
+ }),
+ { status: 200 },
+ ),
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ exit_code: 0,
+ session_id: 'lobe-user-1-topic-1',
+ stderr: '',
+ stdout: JSON.stringify({ bytesWritten: 11, success: true }),
+ }),
+ { status: 200 },
+ ),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { OnlyboxesSandboxProvider } = await import('./onlyboxes');
+ const provider = new OnlyboxesSandboxProvider({
+ marketService: {} as MarketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await provider.callTool('writeLocalFile', {
+ content: 'hello world',
+ createDirectories: true,
+ path: '/workspace/report.txt',
+ });
+
+ expect(result).toMatchObject({
+ result: { bytesWritten: 11, success: true },
+ success: true,
+ });
+ const firstCallBody = JSON.parse(String(fetchMock.mock.calls[0][1].body)) as {
+ command: string;
+ };
+ const secondCallBody = JSON.parse(String(fetchMock.mock.calls[1][1].body)) as {
+ command: string;
+ };
+ expect(firstCallBody.command).toContain("path.write_bytes(b'')");
+ expect(secondCallBody.command).toContain("path.open('ab')");
+ expect(firstCallBody.command).not.toContain('hello world');
+ expect(secondCallBody.command).not.toContain('hello world');
+ });
+
+ it('ensures a terminal session exists before exporting files through terminalResource', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ exit_code: 0,
+ session_id: 'lobe-user-1-topic-1',
+ stderr: '',
+ stdout: '',
+ }),
+ { status: 200 },
+ ),
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ result: {
+ file_path: '/workspace/report.txt',
+ mime_type: 'text/plain',
+ session_id: 'lobe-user-1-topic-1',
+ size_bytes: 12,
+ },
+ status: 'succeeded',
+ task_id: 'task-1',
+ }),
+ { status: 200 },
+ ),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { OnlyboxesSandboxProvider } = await import('./onlyboxes');
+ const provider = new OnlyboxesSandboxProvider({
+ marketService: {} as MarketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await provider.exportFileToUploadUrl({
+ filename: 'report.txt',
+ path: '/workspace/report.txt',
+ uploadHeaders: { 'x-amz-acl': 'public-read' },
+ uploadUrl: 'https://uploads.example.com/put',
+ });
+
+ expect(result).toMatchObject({
+ mimeType: 'text/plain',
+ success: true,
+ });
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 1,
+ 'https://onlyboxes.example.com/api/v1/commands/terminal',
+ expect.any(Object),
+ );
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 2,
+ 'https://onlyboxes.example.com/api/v1/tasks',
+ expect.objectContaining({
+ body: JSON.stringify({
+ capability: 'terminalResource',
+ input: {
+ action: 'export',
+ file_path: '/workspace/report.txt',
+ headers: { 'x-amz-acl': 'public-read' },
+ session_id: 'lobe-user-1-topic-1',
+ signed_url: 'https://uploads.example.com/put',
+ },
+ mode: 'sync',
+ timeout_ms: 120_000,
+ wait_ms: 60_000,
+ }),
+ }),
+ );
+ });
+
+ it('runs execScript from a prepared skill directory when skill zip URLs are available', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ exit_code: 0,
+ session_id: 'lobe-user-1-topic-1',
+ stderr: '',
+ stdout: '',
+ }),
+ { status: 200 },
+ ),
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ exit_code: 0,
+ session_id: 'lobe-user-1-topic-1',
+ stderr: '',
+ stdout: 'from skill\n',
+ }),
+ { status: 200 },
+ ),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { OnlyboxesSandboxProvider } = await import('./onlyboxes');
+ const provider = new OnlyboxesSandboxProvider({
+ marketService: {} as MarketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await provider.callTool('execScript', {
+ activatedSkills: [{ id: 'skill-1', name: 'demo' }],
+ command: 'python scripts/run.py',
+ skillZipUrls: { demo: 'https://files.example.com/demo.zip' },
+ });
+
+ expect(result).toMatchObject({
+ result: {
+ stdout: 'from skill\n',
+ success: true,
+ },
+ success: true,
+ });
+
+ const setupBody = JSON.parse(String(fetchMock.mock.calls[0][1].body)) as { command: string };
+ const commandBody = JSON.parse(String(fetchMock.mock.calls[1][1].body)) as { command: string };
+ expect(setupBody.command).toContain("curl -fsSL 'https://files.example.com/demo.zip'");
+ expect(setupBody.command).toContain('unzip -q');
+ expect(commandBody.command).toContain("cd '/tmp/lobe-skills/");
+ expect(commandBody.command).toContain("/demo'");
+ expect(commandBody.command).toContain('python scripts/run.py');
+ });
+
+ it('prepares all skill zip URLs and runs execScript from the last activated skill directory', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ exit_code: 0,
+ session_id: 'lobe-user-1-topic-1',
+ stderr: '',
+ stdout: '',
+ }),
+ { status: 200 },
+ ),
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ exit_code: 0,
+ session_id: 'lobe-user-1-topic-1',
+ stderr: '',
+ stdout: 'from second skill\n',
+ }),
+ { status: 200 },
+ ),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { OnlyboxesSandboxProvider } = await import('./onlyboxes');
+ const provider = new OnlyboxesSandboxProvider({
+ marketService: {} as MarketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await provider.callTool('execScript', {
+ activatedSkills: [
+ { id: 'skill-1', name: 'first skill' },
+ { id: 'skill-2', name: 'second/skill' },
+ ],
+ command: 'python scripts/run.py',
+ skillZipUrls: {
+ 'first skill': 'https://files.example.com/first.zip',
+ 'second/skill': 'https://files.example.com/second.zip',
+ },
+ });
+
+ expect(result).toMatchObject({
+ result: {
+ stdout: 'from second skill\n',
+ success: true,
+ },
+ success: true,
+ });
+
+ const setupBody = JSON.parse(String(fetchMock.mock.calls[0][1].body)) as { command: string };
+ const commandBody = JSON.parse(String(fetchMock.mock.calls[1][1].body)) as { command: string };
+ expect(setupBody.command).toContain("curl -fsSL 'https://files.example.com/first.zip'");
+ expect(setupBody.command).toContain("curl -fsSL 'https://files.example.com/second.zip'");
+ expect(setupBody.command).toContain('/first-skill/');
+ expect(setupBody.command).toContain('/second-skill/');
+ expect(commandBody.command).toContain("cd '/tmp/lobe-skills/");
+ expect(commandBody.command).toContain("/second-skill'");
+ expect(commandBody.command).toContain('python scripts/run.py');
+ });
+
+ it('uses the configured skill name for legacy single zipUrl execScript calls', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ exit_code: 0,
+ session_id: 'lobe-user-1-topic-1',
+ stderr: '',
+ stdout: '',
+ }),
+ { status: 200 },
+ ),
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ exit_code: 0,
+ session_id: 'lobe-user-1-topic-1',
+ stderr: '',
+ stdout: 'from legacy skill\n',
+ }),
+ { status: 200 },
+ ),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ const { OnlyboxesSandboxProvider } = await import('./onlyboxes');
+ const provider = new OnlyboxesSandboxProvider({
+ marketService: {} as MarketService,
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await provider.callTool('execScript', {
+ command: 'python scripts/run.py',
+ config: { name: 'legacy skill' },
+ zipUrl: 'https://files.example.com/legacy.zip',
+ });
+
+ expect(result).toMatchObject({
+ result: {
+ stdout: 'from legacy skill\n',
+ success: true,
+ },
+ success: true,
+ });
+
+ const setupBody = JSON.parse(String(fetchMock.mock.calls[0][1].body)) as { command: string };
+ const commandBody = JSON.parse(String(fetchMock.mock.calls[1][1].body)) as { command: string };
+ expect(setupBody.command).toContain("curl -fsSL 'https://files.example.com/legacy.zip'");
+ expect(setupBody.command).toContain('/legacy-skill/');
+ expect(commandBody.command).toContain('/legacy-skill');
+ });
+});
diff --git a/src/server/services/sandbox/providers/onlyboxes.ts b/src/server/services/sandbox/providers/onlyboxes.ts
new file mode 100644
index 0000000000..3f9737f001
--- /dev/null
+++ b/src/server/services/sandbox/providers/onlyboxes.ts
@@ -0,0 +1,901 @@
+import { createHmac } from 'node:crypto';
+
+import type { SandboxCallToolResult } from '@lobechat/builtin-tool-cloud-sandbox';
+import { isRecord } from '@lobechat/utils';
+import debug from 'debug';
+import { sha256 } from 'js-sha256';
+
+import { appEnv } from '@/envs/app';
+import { sandboxEnv } from '@/envs/sandbox';
+
+import type {
+ SandboxProvider,
+ SandboxProviderCapabilities,
+ SandboxProviderFileExportRequest,
+ SandboxProviderFileExportResult,
+ SandboxServiceOptions,
+} from '../types';
+
+const log = debug('lobe-server:sandbox:onlyboxes');
+
+const DEFAULT_TIMEOUT_MS = 120_000;
+const EXPORT_TASK_WAIT_MS = 60_000;
+const DEFAULT_LEASE_TTL_SEC = 900;
+const DEFAULT_JIT_TTL_SEC = 1800;
+const JIT_TOKEN_PREFIX = 'obx_jit_v1.';
+const WRITE_FILE_CHUNK_BYTES = 48 * 1024;
+const SKILL_ARCHIVE_CACHE_DIR = '/tmp/lobe-skills';
+
+interface OnlyboxesTaskResponse {
+ error?: { code?: string; message?: string };
+ result?: Record;
+ status?: string;
+ task_id?: string;
+}
+
+interface TerminalExecResult {
+ created?: boolean;
+ exit_code?: number;
+ lease_expires_unix_ms?: number;
+ session_id?: string;
+ stderr?: string;
+ stderr_truncated?: boolean;
+ stdout?: string;
+ stdout_truncated?: boolean;
+}
+
+export class OnlyboxesSandboxProvider implements SandboxProvider {
+ readonly capabilities = {
+ backgroundCommands: true,
+ exportFile: true,
+ files: true,
+ languages: ['python', 'javascript', 'typescript'],
+ persistentSession: true,
+ shell: true,
+ skillScripts: true,
+ } as const satisfies SandboxProviderCapabilities;
+
+ readonly kind = 'onlyboxes';
+
+ private readonly baseUrl: string;
+ private readonly jitIssuer: string;
+ private readonly jitSigningKey: string;
+ private readonly jitTTLSec: number;
+ private readonly leaseTTLSec: number;
+ private readonly options: SandboxServiceOptions;
+
+ constructor(options: SandboxServiceOptions) {
+ this.options = options;
+ this.baseUrl = (sandboxEnv.ONLYBOXES_BASE_URL || '').replace(/\/+$/, '');
+ this.jitIssuer = sandboxEnv.ONLYBOXES_JIT_ISSUER || appEnv.APP_URL || 'lobehub';
+ this.jitSigningKey = sandboxEnv.ONLYBOXES_JIT_SIGNING_KEY || '';
+ this.jitTTLSec = sandboxEnv.ONLYBOXES_JIT_TTL_SEC || DEFAULT_JIT_TTL_SEC;
+ this.leaseTTLSec = sandboxEnv.ONLYBOXES_LEASE_TTL_SEC || DEFAULT_LEASE_TTL_SEC;
+ }
+
+ async callTool(
+ toolName: string,
+ params: Record,
+ ): Promise {
+ if (!this.baseUrl || !this.jitSigningKey) {
+ return this.errorResult('ONLYBOXES_BASE_URL and ONLYBOXES_JIT_SIGNING_KEY are required');
+ }
+
+ try {
+ switch (toolName) {
+ case 'runCommand': {
+ return this.runCommand(params);
+ }
+
+ case 'getCommandOutput': {
+ return this.getCommandOutput(params);
+ }
+
+ case 'killCommand': {
+ return this.killCommand(params);
+ }
+
+ case 'executeCode': {
+ return this.executeCode(params);
+ }
+
+ case 'execScript': {
+ return this.execScript(params);
+ }
+
+ case 'listLocalFiles': {
+ return this.runJsonScript(listFilesScript, params);
+ }
+
+ case 'listFiles': {
+ return this.runJsonScript(listFilesScript, params);
+ }
+
+ case 'readLocalFile': {
+ return this.runJsonScript(readFileScript, params);
+ }
+
+ case 'readFile': {
+ return this.runJsonScript(readFileScript, params);
+ }
+
+ case 'writeLocalFile': {
+ return this.writeLocalFile(params);
+ }
+
+ case 'writeFile': {
+ return this.writeLocalFile(params);
+ }
+
+ case 'editLocalFile': {
+ return this.runJsonScript(editFileScript, params);
+ }
+
+ case 'editFile': {
+ return this.runJsonScript(editFileScript, params);
+ }
+
+ case 'searchLocalFiles': {
+ return this.runJsonScript(searchFilesScript, params);
+ }
+
+ case 'searchFiles': {
+ return this.runJsonScript(searchFilesScript, params);
+ }
+
+ case 'moveLocalFiles': {
+ return this.runJsonScript(moveFilesScript, params);
+ }
+
+ case 'moveFiles': {
+ return this.runJsonScript(moveFilesScript, params);
+ }
+
+ case 'grepContent': {
+ return this.runJsonScript(grepContentScript, params);
+ }
+
+ case 'globLocalFiles': {
+ return this.runJsonScript(globFilesScript, params);
+ }
+
+ case 'globFiles': {
+ return this.runJsonScript(globFilesScript, params);
+ }
+
+ default: {
+ return this.errorResult(`Unsupported Onlyboxes sandbox tool: ${toolName}`);
+ }
+ }
+ } catch (error) {
+ log('Onlyboxes tool %s failed: %O', toolName, error);
+ return this.errorResult((error as Error).message, (error as Error).name);
+ }
+ }
+
+ async exportFileToUploadUrl({
+ path,
+ uploadHeaders,
+ uploadUrl,
+ }: SandboxProviderFileExportRequest): Promise {
+ if (!this.baseUrl || !this.jitSigningKey) {
+ return {
+ error: { message: 'ONLYBOXES_BASE_URL and ONLYBOXES_JIT_SIGNING_KEY are required' },
+ success: false,
+ };
+ }
+
+ try {
+ await this.ensureSession();
+
+ const task = await this.submitTask('terminalResource', {
+ action: 'export',
+ file_path: path,
+ headers: uploadHeaders,
+ session_id: this.sessionId,
+ signed_url: uploadUrl,
+ });
+
+ if (task.status !== 'succeeded') {
+ return {
+ error: { message: task.error?.message || 'Failed to export file from Onlyboxes sandbox' },
+ success: false,
+ };
+ }
+
+ return {
+ mimeType: String(task.result?.mime_type || ''),
+ result: task.result,
+ size: typeof task.result?.size_bytes === 'number' ? task.result.size_bytes : undefined,
+ success: true,
+ };
+ } catch (error) {
+ log('Onlyboxes export failed: %O', error);
+ return {
+ error: { message: (error as Error).message },
+ success: false,
+ };
+ }
+ }
+
+ private get sessionId() {
+ const scope = `${this.options.userId}-${this.options.topicId}`;
+ return `lobe-${scope.replaceAll(/[^\w.-]/g, '-')}`;
+ }
+
+ private async executeCode(params: Record): Promise {
+ const code = String(params.code || '');
+ const language = String(params.language || 'python');
+
+ const runners: Record = {
+ javascript: 'node',
+ python: 'python3',
+ typescript: 'npx --yes tsx',
+ };
+ const extensions: Record = {
+ javascript: 'js',
+ python: 'py',
+ typescript: 'ts',
+ };
+ const runner = runners[language];
+
+ if (!runner) {
+ return this.errorResult(`Unsupported code language for Onlyboxes sandbox: ${language}`);
+ }
+
+ const filePath = `/tmp/lobe-code-${Date.now()}.${extensions[language]}`;
+ const writeResult = await this.writeTextFile({
+ content: code,
+ createDirectories: true,
+ path: filePath,
+ timeoutMs: this.timeout(params),
+ });
+
+ if (!writeResult.success) {
+ return writeResult;
+ }
+
+ const command = `${runner} '${filePath}'`;
+ const terminal = await this.execTerminal(command, this.timeout(params));
+
+ return {
+ result: {
+ error: terminal.exit_code === 0 ? undefined : terminal.stderr,
+ exitCode: terminal.exit_code,
+ output: terminal.stdout,
+ stderr: terminal.stderr,
+ },
+ success: true,
+ };
+ }
+
+ private async execScript(params: Record): Promise {
+ const command = String(params.command || '');
+
+ if (!command.trim()) {
+ return this.errorResult('command is required');
+ }
+
+ const skillZipUrls = this.resolveExecScriptZipUrls(params);
+ const timeoutMs = this.timeout(params);
+
+ if (Object.keys(skillZipUrls).length === 0) {
+ return this.runCommand({ command, timeout: timeoutMs });
+ }
+
+ const defaultSkillName = this.resolveExecScriptSkillName(params, skillZipUrls);
+ const workspaceDir = this.skillWorkspaceDir(skillZipUrls);
+ const setupCommand = this.buildSkillSetupCommand({ skillZipUrls, workspaceDir });
+ const setup = await this.execTerminal(setupCommand, timeoutMs);
+
+ if (setup.exit_code !== 0) {
+ return {
+ error: { message: setup.stderr || setup.stdout || 'Failed to prepare skill resources' },
+ result: {
+ exitCode: setup.exit_code,
+ output: setup.stdout,
+ stderr: setup.stderr,
+ },
+ success: false,
+ };
+ }
+
+ const runDir = defaultSkillName
+ ? `${workspaceDir}/${this.safeSkillDirName(defaultSkillName)}`
+ : workspaceDir;
+ const result = await this.execTerminal(
+ `cd ${this.shellQuote(runDir)} && ${command}`,
+ timeoutMs,
+ );
+
+ return {
+ result: {
+ commandId: result.session_id,
+ exitCode: result.exit_code,
+ output: result.stdout,
+ stderr: result.stderr,
+ stdout: result.stdout,
+ success: result.exit_code === 0,
+ },
+ success: true,
+ };
+ }
+
+ private async runCommand(params: Record): Promise {
+ const command = String(params.command || '');
+
+ if (!command.trim()) {
+ return this.errorResult('command is required');
+ }
+
+ if (params.background === true) {
+ const task = await this.submitTask(
+ 'terminalExec',
+ {
+ command,
+ create_if_missing: true,
+ lease_ttl_sec: this.leaseTTLSec,
+ session_id: this.sessionId,
+ },
+ { mode: 'async', timeoutMs: this.timeout(params) },
+ );
+
+ if (task.error || !task.task_id) {
+ return this.errorResult(
+ task.error?.message || task.error?.code || 'Failed to start Onlyboxes background command',
+ );
+ }
+
+ return {
+ result: {
+ commandId: task.task_id,
+ shell_id: task.task_id,
+ },
+ success: true,
+ };
+ }
+
+ const terminal = await this.execTerminal(command, this.timeout(params));
+
+ return {
+ result: {
+ commandId: terminal.session_id,
+ exitCode: terminal.exit_code,
+ output: terminal.stdout,
+ stderr: terminal.stderr,
+ stdout: terminal.stdout,
+ success: terminal.exit_code === 0,
+ },
+ success: true,
+ };
+ }
+
+ private resolveExecScriptZipUrls(params: Record) {
+ const zipUrl = typeof params.zipUrl === 'string' ? params.zipUrl : undefined;
+ if (zipUrl) return { [this.resolveLegacyExecScriptSkillName(params)]: zipUrl };
+
+ if (!isRecord(params.skillZipUrls)) return {};
+
+ const result: Record = {};
+
+ for (const [name, value] of Object.entries(params.skillZipUrls)) {
+ if (typeof value === 'string' && value) {
+ result[name] = value;
+ }
+ }
+
+ return result;
+ }
+
+ private resolveLegacyExecScriptSkillName(params: Record) {
+ const configName = isRecord(params.config) ? params.config.name : undefined;
+ if (typeof configName === 'string' && configName) return configName;
+
+ if (Array.isArray(params.activatedSkills)) {
+ for (const skill of [...params.activatedSkills].reverse()) {
+ if (!isRecord(skill)) continue;
+
+ const name = typeof skill.name === 'string' ? skill.name : undefined;
+ if (name) return name;
+ }
+ }
+
+ return 'default';
+ }
+
+ private resolveExecScriptSkillName(
+ params: Record,
+ skillZipUrls: Record,
+ ) {
+ const configName = isRecord(params.config) ? params.config.name : undefined;
+ if (typeof configName === 'string' && skillZipUrls[configName]) return configName;
+
+ if (Array.isArray(params.activatedSkills)) {
+ for (const skill of [...params.activatedSkills].reverse()) {
+ if (!isRecord(skill)) continue;
+
+ const name = typeof skill.name === 'string' ? skill.name : undefined;
+ if (name && skillZipUrls[name]) return name;
+ }
+ }
+
+ const [firstName] = Object.keys(skillZipUrls);
+ return firstName;
+ }
+
+ private skillWorkspaceDir(skillZipUrls: Record) {
+ const entries = Object.entries(skillZipUrls).sort(([left], [right]) =>
+ left.localeCompare(right),
+ );
+ const cacheKey = sha256(JSON.stringify(entries)).slice(0, 32);
+ return `${SKILL_ARCHIVE_CACHE_DIR}/${cacheKey || 'default'}`;
+ }
+
+ private buildSkillSetupCommand({
+ skillZipUrls,
+ workspaceDir,
+ }: {
+ skillZipUrls: Record;
+ workspaceDir: string;
+ }) {
+ const quotedWorkspaceDir = this.shellQuote(workspaceDir);
+ const setupCommands = Object.entries(skillZipUrls).map(([name, zipUrl]) => {
+ const skillDir = `${workspaceDir}/${this.safeSkillDirName(name)}`;
+ const markerPath = `${skillDir}/.prepared`;
+ const archivePath = `${skillDir}/skill.zip`;
+ const quotedArchivePath = this.shellQuote(archivePath);
+ const quotedDir = this.shellQuote(skillDir);
+ const quotedMarkerPath = this.shellQuote(markerPath);
+ const quotedUrl = this.shellQuote(zipUrl);
+
+ return `if [ ! -f ${quotedMarkerPath} ]; then rm -rf ${quotedDir} && mkdir -p ${quotedDir} && curl -fsSL ${quotedUrl} -o ${quotedArchivePath} && unzip -q ${quotedArchivePath} -d ${quotedDir} && printf prepared > ${quotedMarkerPath}; fi`;
+ });
+
+ return [
+ `mkdir -p ${this.shellQuote(SKILL_ARCHIVE_CACHE_DIR)}`,
+ `mkdir -p ${quotedWorkspaceDir}`,
+ ...setupCommands,
+ ].join(' && ');
+ }
+
+ private safeSkillDirName(name: string) {
+ return name.replaceAll(/[^\w.-]/g, '-');
+ }
+
+ private shellQuote(value: string) {
+ return `'${value.replaceAll("'", "'\\''")}'`;
+ }
+
+ private async writeLocalFile(params: Record): Promise {
+ const path = String(params.path || '');
+
+ if (!path) {
+ return this.errorResult('path is required');
+ }
+
+ return this.writeTextFile({
+ content: String(params.content || ''),
+ createDirectories: params.createDirectories === true,
+ path,
+ timeoutMs: this.timeout(params),
+ });
+ }
+
+ private async writeTextFile({
+ content,
+ createDirectories,
+ path,
+ timeoutMs,
+ }: {
+ content: string;
+ createDirectories: boolean;
+ path: string;
+ timeoutMs: number;
+ }): Promise {
+ const init = await this.runJsonScript(
+ prepareWriteFileScript,
+ { createDirectories, path },
+ timeoutMs,
+ );
+
+ if (!init.success) {
+ return init;
+ }
+
+ const bytes = Buffer.from(content);
+ let bytesWritten = 0;
+
+ for (let offset = 0; offset < bytes.length; offset += WRITE_FILE_CHUNK_BYTES) {
+ const chunk = bytes.subarray(offset, offset + WRITE_FILE_CHUNK_BYTES).toString('base64');
+ const append = await this.runJsonScript(
+ appendWriteFileChunkScript,
+ { chunk, path },
+ timeoutMs,
+ );
+
+ if (!append.success) {
+ return append;
+ }
+
+ bytesWritten += Number(append.result?.bytesWritten || 0);
+ }
+
+ return {
+ result: {
+ bytesWritten,
+ success: true,
+ },
+ success: true,
+ };
+ }
+
+ private async getCommandOutput(params: Record): Promise {
+ const commandId = String(params.commandId || '');
+ if (!commandId) return this.errorResult('commandId is required');
+
+ const task = await this.request(`/api/v1/tasks/${commandId}`, {
+ method: 'GET',
+ });
+
+ const running =
+ task.status === 'running' || task.status === 'pending' || task.status === 'dispatched';
+ const success = running || task.status === 'succeeded';
+ const result = task.result || {};
+
+ return {
+ error: task.error
+ ? { message: task.error.message || task.error.code || 'Task failed' }
+ : undefined,
+ result: {
+ error: task.error?.message,
+ newOutput: String(result.stdout || result.output || ''),
+ output: String(result.stdout || result.output || ''),
+ running,
+ stderr: String(result.stderr || ''),
+ success,
+ },
+ success: !task.error,
+ };
+ }
+
+ private async killCommand(params: Record): Promise {
+ const commandId = String(params.commandId || '');
+ if (!commandId) return this.errorResult('commandId is required');
+
+ const task = await this.request(`/api/v1/tasks/${commandId}/cancel`, {
+ method: 'POST',
+ });
+
+ return {
+ error: task.error
+ ? { message: task.error.message || task.error.code || 'Failed to cancel task' }
+ : undefined,
+ result: {
+ success: !task.error,
+ },
+ success: !task.error,
+ };
+ }
+
+ private async runJsonScript(
+ script: string,
+ params: Record,
+ timeoutMs = this.timeout(params),
+ ): Promise {
+ const encoded = Buffer.from(JSON.stringify(params)).toString('base64');
+ const command = `python3 - <<'PY'\n${script}\nmain('${encoded}')\nPY`;
+ const terminal = await this.execTerminal(command, timeoutMs);
+
+ if (terminal.exit_code !== 0) {
+ return {
+ error: { message: terminal.stderr || terminal.stdout || 'Onlyboxes script failed' },
+ result: null,
+ success: false,
+ };
+ }
+
+ try {
+ const result = JSON.parse(terminal.stdout || '{}') as Record;
+
+ if (result.success === false) {
+ return {
+ error: { message: String(result.error || 'Onlyboxes script failed') },
+ result,
+ success: false,
+ };
+ }
+
+ return {
+ result,
+ success: true,
+ };
+ } catch (error) {
+ return {
+ error: { message: `Failed to parse Onlyboxes script output: ${(error as Error).message}` },
+ result: { output: terminal.stdout, stderr: terminal.stderr },
+ success: false,
+ };
+ }
+ }
+
+ private async execTerminal(command: string, timeoutMs = DEFAULT_TIMEOUT_MS) {
+ return this.request('/api/v1/commands/terminal', {
+ body: JSON.stringify({
+ command,
+ create_if_missing: true,
+ lease_ttl_sec: this.leaseTTLSec,
+ session_id: this.sessionId,
+ timeout_ms: timeoutMs,
+ }),
+ method: 'POST',
+ });
+ }
+
+ private async ensureSession() {
+ await this.execTerminal(':', DEFAULT_TIMEOUT_MS);
+ }
+
+ private async submitTask(
+ capability: string,
+ input: Record,
+ options?: { mode?: 'async' | 'auto' | 'sync'; timeoutMs?: number },
+ ) {
+ return this.request('/api/v1/tasks', {
+ body: JSON.stringify({
+ capability,
+ input,
+ mode: options?.mode || 'sync',
+ timeout_ms: options?.timeoutMs || DEFAULT_TIMEOUT_MS,
+ wait_ms: options?.mode === 'async' ? 1 : EXPORT_TASK_WAIT_MS,
+ }),
+ method: 'POST',
+ });
+ }
+
+ private async request(path: string, init: RequestInit): Promise {
+ const headers = new Headers(init.headers);
+ headers.set('Authorization', `Bearer ${this.createJITToken()}`);
+ headers.set('Content-Type', 'application/json');
+
+ const response = await fetch(`${this.baseUrl}${path}`, {
+ ...init,
+ headers,
+ });
+ const body = await response.text();
+ const json = body ? JSON.parse(body) : {};
+
+ if (!response.ok) {
+ const message =
+ typeof json?.error === 'string'
+ ? json.error
+ : typeof json?.error?.message === 'string'
+ ? json.error.message
+ : `Onlyboxes request failed with HTTP ${response.status}`;
+ throw new Error(message);
+ }
+
+ return json as T;
+ }
+
+ private createJITToken(now = Date.now()) {
+ const claims = {
+ exp: now + this.jitTTLSec * 1000,
+ iss: this.jitIssuer,
+ sub: this.options.userId,
+ };
+ const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
+ const signed = `${JIT_TOKEN_PREFIX}${payload}`;
+ const signature = createHmac('sha256', this.jitSigningKey).update(signed).digest('base64url');
+
+ return `${signed}.${signature}`;
+ }
+
+ private timeout(params: Record) {
+ const value = params.timeout ?? params.timeout_ms;
+ return typeof value === 'number' && Number.isFinite(value) ? value : DEFAULT_TIMEOUT_MS;
+ }
+
+ private errorResult(message: string, name?: string): SandboxCallToolResult {
+ return {
+ error: { message, name },
+ result: null,
+ success: false,
+ };
+ }
+}
+
+const scriptPrelude = `
+import base64, json, os, re, shutil, glob, fnmatch
+from pathlib import Path
+
+def load_args(encoded):
+ return json.loads(base64.b64decode(encoded).decode())
+
+def emit(value):
+ print(json.dumps(value, ensure_ascii=False))
+`;
+
+const listFilesScript = `${scriptPrelude}
+def main(encoded):
+ args = load_args(encoded)
+ directory = args.get('directoryPath') or '.'
+ entries = []
+ for entry in os.scandir(directory):
+ stat = entry.stat()
+ entries.append({
+ 'name': entry.name,
+ 'path': entry.path,
+ 'isDirectory': entry.is_dir(),
+ 'size': stat.st_size,
+ 'mtime': stat.st_mtime,
+ })
+ emit({'files': entries, 'totalCount': len(entries)})
+`;
+
+const readFileScript = `${scriptPrelude}
+def main(encoded):
+ args = load_args(encoded)
+ path = args.get('path')
+ start = args.get('startLine')
+ end = args.get('endLine')
+ text = Path(path).read_text(errors='replace')
+ lines = text.splitlines(True)
+ selected = lines
+ if start is not None or end is not None:
+ start_idx = max((start or 1) - 1, 0)
+ end_idx = end if end is not None else len(lines)
+ selected = lines[start_idx:end_idx]
+ content = ''.join(selected)
+ emit({
+ 'content': content,
+ 'filename': os.path.basename(path),
+ 'charCount': len(content),
+ 'totalCharCount': len(text),
+ 'totalLineCount': len(lines),
+ })
+`;
+
+const prepareWriteFileScript = `${scriptPrelude}
+def main(encoded):
+ args = load_args(encoded)
+ path = Path(args.get('path'))
+ if args.get('createDirectories'):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(b'')
+ emit({'success': True})
+`;
+
+const appendWriteFileChunkScript = `${scriptPrelude}
+def main(encoded):
+ args = load_args(encoded)
+ path = Path(args.get('path'))
+ chunk = base64.b64decode(args.get('chunk') or '')
+ with path.open('ab') as file:
+ file.write(chunk)
+ emit({'bytesWritten': len(chunk), 'success': True})
+`;
+
+const editFileScript = `${scriptPrelude}
+def main(encoded):
+ args = load_args(encoded)
+ path = Path(args.get('path'))
+ search = args.get('search') or ''
+ replace = args.get('replace') or ''
+ text = path.read_text(errors='replace')
+ count = text.count(search)
+ if count == 0:
+ emit({'success': False, 'error': 'search text not found', 'replacements': 0})
+ return
+ new_text = text.replace(search, replace) if args.get('all') else text.replace(search, replace, 1)
+ replacements = count if args.get('all') else 1
+ path.write_text(new_text)
+ emit({'success': True, 'replacements': replacements, 'linesAdded': replace.count('\\n'), 'linesDeleted': search.count('\\n')})
+`;
+
+const searchFilesScript = `${scriptPrelude}
+from datetime import datetime
+
+def parse_time(value):
+ if not value:
+ return None
+ try:
+ return datetime.fromisoformat(str(value).replace('Z', '+00:00')).timestamp()
+ except Exception:
+ return None
+
+def main(encoded):
+ args = load_args(encoded)
+ directory = args.get('directory') or '.'
+ raw_keywords = args.get('keywords') or args.get('keyword') or ''
+ keywords = [item.strip() for item in str(raw_keywords).split() if item.strip()]
+ raw_file_types = args.get('fileTypes') or args.get('fileType') or []
+ if isinstance(raw_file_types, str):
+ raw_file_types = [raw_file_types]
+ file_types = [item if str(item).startswith('.') else f'.{item}' for item in raw_file_types if str(item).strip()]
+ modified_after = parse_time(args.get('modifiedAfter'))
+ modified_before = parse_time(args.get('modifiedBefore'))
+ content_contains = args.get('contentContains')
+ limit = args.get('limit')
+ results = []
+ for root, _, files in os.walk(directory):
+ for name in files:
+ if keywords and not all(keyword in name for keyword in keywords):
+ continue
+ if file_types and not any(name.endswith(file_type) for file_type in file_types):
+ continue
+ path = os.path.join(root, name)
+ try:
+ stat = os.stat(path)
+ except Exception:
+ continue
+ if modified_after is not None and stat.st_mtime < modified_after:
+ continue
+ if modified_before is not None and stat.st_mtime > modified_before:
+ continue
+ if content_contains:
+ try:
+ if str(content_contains) not in Path(path).read_text(errors='replace'):
+ continue
+ except Exception:
+ continue
+ results.append({'name': name, 'path': path, 'size': stat.st_size, 'mtime': stat.st_mtime})
+ sort_by = args.get('sortBy')
+ reverse = args.get('sortDirection') == 'desc'
+ if sort_by == 'size':
+ results.sort(key=lambda item: item.get('size') or 0, reverse=reverse)
+ elif sort_by == 'date':
+ results.sort(key=lambda item: item.get('mtime') or 0, reverse=reverse)
+ else:
+ results.sort(key=lambda item: item.get('name') or '', reverse=reverse)
+ total = len(results)
+ if isinstance(limit, int) and limit > 0:
+ results = results[:limit]
+ emit({'results': results, 'totalCount': total})
+`;
+
+const moveFilesScript = `${scriptPrelude}
+def main(encoded):
+ args = load_args(encoded)
+ results = []
+ for op in args.get('operations') or []:
+ try:
+ shutil.move(op.get('source'), op.get('destination'))
+ results.append({'source': op.get('source'), 'destination': op.get('destination'), 'success': True})
+ except Exception as error:
+ results.append({'source': op.get('source'), 'destination': op.get('destination'), 'success': False, 'error': str(error)})
+ emit({'results': results, 'successCount': len([r for r in results if r.get('success')])})
+`;
+
+const grepContentScript = `${scriptPrelude}
+def main(encoded):
+ args = load_args(encoded)
+ directory = args.get('directory') or '.'
+ pattern = args.get('pattern') or ''
+ file_pattern = args.get('filePattern') or '*'
+ recursive = args.get('recursive', True)
+ regex = re.compile(pattern)
+ matches = []
+ walker = os.walk(directory) if recursive else [(directory, [], os.listdir(directory))]
+ for root, _, files in walker:
+ for name in files:
+ if not fnmatch.fnmatch(name, file_pattern):
+ continue
+ path = os.path.join(root, name)
+ try:
+ with open(path, 'r', errors='replace') as file:
+ for index, line in enumerate(file, 1):
+ if regex.search(line):
+ matches.append({'path': path, 'lineNumber': index, 'line': line.rstrip('\\n')})
+ except Exception:
+ pass
+ emit({'matches': matches, 'totalMatches': len(matches)})
+`;
+
+const globFilesScript = `${scriptPrelude}
+def main(encoded):
+ args = load_args(encoded)
+ directory = args.get('directory') or '.'
+ pattern = args.get('pattern') or '*'
+ files = glob.glob(os.path.join(directory, pattern), recursive=True)
+ emit({'files': files, 'totalCount': len(files)})
+`;
diff --git a/src/server/services/sandbox/service.ts b/src/server/services/sandbox/service.ts
new file mode 100644
index 0000000000..cc97e5d282
--- /dev/null
+++ b/src/server/services/sandbox/service.ts
@@ -0,0 +1,135 @@
+import type {
+ SandboxCallToolResult,
+ SandboxExportFileResult,
+} from '@lobechat/builtin-tool-cloud-sandbox';
+import debug from 'debug';
+import { sha256 } from 'js-sha256';
+
+import type {
+ SandboxCommandResult,
+ SandboxProvider,
+ SandboxProviderCapabilities,
+ SandboxProviderKind,
+ SandboxService,
+ SandboxServiceOptions,
+} from './types';
+
+const log = debug('lobe-server:sandbox:service');
+
+export class SandboxMiddlewareService implements SandboxService {
+ readonly capabilities: SandboxProviderCapabilities;
+ readonly kind: SandboxProviderKind;
+
+ constructor(
+ private readonly provider: SandboxProvider,
+ private readonly options: SandboxServiceOptions,
+ ) {
+ this.capabilities = provider.capabilities;
+ this.kind = provider.kind;
+ }
+
+ callTool(toolName: string, params: Record): Promise {
+ return this.provider.callTool(toolName, params);
+ }
+
+ async exportAndUploadFile(path: string, filename: string): Promise {
+ const { fileService, topicId } = this.options;
+
+ if (!fileService) {
+ return {
+ error: { message: 'fileService is required for sandbox file export' },
+ filename,
+ success: false,
+ };
+ }
+
+ log('Exporting file: %s from path: %s, topicId: %s', filename, path, topicId);
+
+ try {
+ const now = Date.now();
+ const today = new Date(now).toISOString().split('T')[0];
+ const key = `code-interpreter-exports/${today}/${topicId}/${filename}`;
+ const upload = await fileService.createPreSignedUpload(key);
+
+ const exported = await this.provider.exportFileToUploadUrl({
+ filename,
+ path,
+ uploadHeaders: upload.headers,
+ uploadUrl: upload.url,
+ });
+
+ if (!exported.success) {
+ return {
+ error: {
+ message: exported.error?.message || 'Failed to export file from sandbox',
+ name: exported.error?.name,
+ },
+ filename,
+ success: false,
+ };
+ }
+
+ const metadata = await fileService.getFileMetadata(key);
+ const fileSize = metadata.contentLength;
+ const mimeType =
+ metadata.contentType ||
+ exported.mimeType ||
+ String(exported.result?.mimeType || '') ||
+ String(exported.result?.mime_type || '') ||
+ 'application/octet-stream';
+ const fileHash = sha256(key + now.toString());
+
+ const { fileId, url } = await fileService.createFileRecord({
+ fileHash,
+ fileType: mimeType,
+ name: filename,
+ size: fileSize,
+ url: key,
+ });
+
+ return {
+ fileId,
+ filename,
+ mimeType,
+ size: fileSize,
+ success: true,
+ url,
+ };
+ } catch (error) {
+ log('Error exporting file: %O', error);
+
+ return {
+ error: { message: (error as Error).message },
+ filename,
+ success: false,
+ };
+ }
+ }
+}
+
+export const normalizeSandboxCommandResult = (
+ result: SandboxCallToolResult,
+): SandboxCommandResult => {
+ if (!result.success) {
+ return {
+ exitCode: 1,
+ output: '',
+ stderr: result.error?.message || 'Command execution failed',
+ success: false,
+ };
+ }
+
+ const raw = result.result || {};
+ const rawExitCode = raw.exitCode ?? raw.exit_code;
+ const exitCode = typeof rawExitCode === 'number' ? rawExitCode : 0;
+ const output = String(raw.stdout || raw.output || '');
+ const stderr = raw.stderr === undefined ? undefined : String(raw.stderr);
+ const success = typeof raw.success === 'boolean' ? raw.success : exitCode === 0;
+
+ return {
+ exitCode,
+ output,
+ stderr,
+ success,
+ };
+};
diff --git a/src/server/services/sandbox/types.ts b/src/server/services/sandbox/types.ts
new file mode 100644
index 0000000000..ecf71d8f92
--- /dev/null
+++ b/src/server/services/sandbox/types.ts
@@ -0,0 +1,70 @@
+import type {
+ ISandboxService,
+ SandboxExportFileResult,
+} from '@lobechat/builtin-tool-cloud-sandbox';
+
+import type { FileService } from '@/server/services/file';
+import type { MarketService } from '@/server/services/market';
+
+export type SandboxProviderKind = 'market' | 'onlyboxes';
+
+export interface SandboxSessionContext {
+ topicId: string;
+ userId: string;
+}
+
+export interface SandboxServiceOptions extends SandboxSessionContext {
+ fileService?: FileService;
+ marketService: MarketService;
+}
+
+export interface SandboxProviderCapabilities {
+ backgroundCommands: boolean;
+ exportFile: boolean;
+ files: boolean;
+ languages: string[];
+ persistentSession: boolean;
+ shell: boolean;
+ skillScripts: boolean;
+}
+
+export interface SandboxProvider extends Pick {
+ readonly capabilities: SandboxProviderCapabilities;
+
+ exportFileToUploadUrl: (
+ request: SandboxProviderFileExportRequest,
+ ) => Promise;
+
+ readonly kind: SandboxProviderKind;
+}
+
+export interface SandboxService extends ISandboxService {
+ readonly capabilities: SandboxProviderCapabilities;
+ readonly kind: SandboxProviderKind;
+}
+
+export interface SandboxFileExporter {
+ exportAndUploadFile: (path: string, filename: string) => Promise;
+}
+
+export interface SandboxProviderFileExportRequest {
+ filename: string;
+ path: string;
+ uploadHeaders?: Record;
+ uploadUrl: string;
+}
+
+export interface SandboxProviderFileExportResult {
+ error?: { message: string; name?: string };
+ mimeType?: string;
+ result?: Record;
+ size?: number;
+ success: boolean;
+}
+
+export interface SandboxCommandResult {
+ exitCode: number;
+ output: string;
+ stderr?: string;
+ success: boolean;
+}
diff --git a/src/server/services/skill/importer.test.ts b/src/server/services/skill/importer.test.ts
index 3dbd45409f..3f027af14a 100644
--- a/src/server/services/skill/importer.test.ts
+++ b/src/server/services/skill/importer.test.ts
@@ -61,6 +61,7 @@ vi.stubGlobal('fetch', mockFetch);
vi.mock('@/server/services/file/impls', () => ({
createFileServiceModule: vi.fn().mockImplementation(() => ({
createPreSignedUrl: vi.fn().mockResolvedValue('mock-presigned-url'),
+ createPreSignedUpload: vi.fn().mockResolvedValue({ url: 'mock-presigned-url' }),
createPreSignedUrlForPreview: vi.fn().mockResolvedValue('mock-preview-url'),
deleteFile: vi.fn().mockResolvedValue(undefined),
deleteFiles: vi.fn().mockResolvedValue(undefined),
@@ -573,6 +574,7 @@ describe('SkillImporter', () => {
const mockUploadBuffer = vi.fn().mockResolvedValue({ key: 'mock-key' });
(createFileServiceModule as any).mockReturnValue({
createPreSignedUrl: vi.fn(),
+ createPreSignedUpload: vi.fn(),
createPreSignedUrlForPreview: vi.fn(),
deleteFile: vi.fn(),
deleteFiles: vi.fn(),
@@ -645,6 +647,7 @@ describe('SkillImporter', () => {
const mockUploadBuffer = vi.fn().mockResolvedValue({ key: 'mock-key' });
(createFileServiceModule as any).mockReturnValue({
createPreSignedUrl: vi.fn(),
+ createPreSignedUpload: vi.fn(),
createPreSignedUrlForPreview: vi.fn(),
deleteFile: vi.fn(),
deleteFiles: vi.fn(),
@@ -748,6 +751,7 @@ describe('SkillImporter', () => {
const mockUploadBuffer = vi.fn().mockResolvedValue({ key: 'mock-key' });
(createFileServiceModule as any).mockReturnValue({
createPreSignedUrl: vi.fn(),
+ createPreSignedUpload: vi.fn(),
createPreSignedUrlForPreview: vi.fn(),
deleteFile: vi.fn(),
deleteFiles: vi.fn(),
@@ -1013,6 +1017,7 @@ description: A nested skill
const { createFileServiceModule } = await import('@/server/services/file/impls');
(createFileServiceModule as any).mockReturnValue({
createPreSignedUrl: vi.fn().mockResolvedValue('mock-presigned-url'),
+ createPreSignedUpload: vi.fn().mockResolvedValue({ url: 'mock-presigned-url' }),
createPreSignedUrlForPreview: vi.fn().mockResolvedValue('mock-preview-url'),
deleteFile: vi.fn().mockResolvedValue(undefined),
deleteFiles: vi.fn().mockResolvedValue(undefined),
diff --git a/src/server/services/toolExecution/serverRuntimes/__tests__/skills.test.ts b/src/server/services/toolExecution/serverRuntimes/__tests__/skills.test.ts
new file mode 100644
index 0000000000..2a223b1280
--- /dev/null
+++ b/src/server/services/toolExecution/serverRuntimes/__tests__/skills.test.ts
@@ -0,0 +1,158 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => {
+ const sandboxService = {
+ callTool: vi.fn(),
+ capabilities: {
+ backgroundCommands: true,
+ exportFile: true,
+ files: true,
+ languages: ['python'],
+ persistentSession: true,
+ shell: true,
+ skillScripts: true,
+ },
+ exportAndUploadFile: vi.fn(),
+ kind: 'onlyboxes',
+ };
+
+ return {
+ checkHash: vi.fn(),
+ createSandboxService: vi.fn(() => sandboxService),
+ fileService: {
+ getFullFileUrl: vi.fn(),
+ },
+ findAll: vi.fn(),
+ findById: vi.fn(),
+ findByName: vi.fn(),
+ getAgentSkills: vi.fn(),
+ getUserSettings: vi.fn(),
+ marketService: {},
+ readResource: vi.fn(),
+ sandboxService,
+ };
+});
+
+vi.mock('@lobechat/builtin-skills', () => ({
+ builtinSkills: [],
+}));
+
+vi.mock('@/database/models/agentSkill', () => ({
+ AgentSkillModel: vi.fn(() => ({
+ findAll: mocks.findAll,
+ findById: mocks.findById,
+ findByName: mocks.findByName,
+ })),
+}));
+
+vi.mock('@/database/models/file', () => ({
+ FileModel: vi.fn(() => ({
+ checkHash: mocks.checkHash,
+ })),
+}));
+
+vi.mock('@/database/models/user', () => ({
+ UserModel: vi.fn(() => ({
+ getUserSettings: mocks.getUserSettings,
+ })),
+}));
+
+vi.mock('@/helpers/skillFilters', () => ({
+ filterBuiltinSkills: vi.fn((skills: unknown) => skills),
+}));
+
+vi.mock('@/server/services/agentDocuments', () => ({
+ AgentDocumentsService: vi.fn(() => ({
+ getAgentSkills: mocks.getAgentSkills,
+ })),
+}));
+
+vi.mock('@/server/services/file', () => ({
+ FileService: vi.fn(() => mocks.fileService),
+}));
+
+vi.mock('@/server/services/market', () => ({
+ MarketService: vi.fn(() => mocks.marketService),
+}));
+
+vi.mock('@/server/services/sandbox', async () => {
+ const actual = await vi.importActual('@/server/services/sandbox');
+
+ return {
+ ...(actual as Record),
+ createSandboxService: mocks.createSandboxService,
+ };
+});
+
+vi.mock('@/server/services/skill/resource', () => ({
+ SkillResourceService: vi.fn(() => ({
+ readResource: mocks.readResource,
+ })),
+}));
+
+describe('skillsRuntime', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mocks.checkHash.mockResolvedValue({ isExist: true, url: 'skills/user-skill.zip' });
+ mocks.fileService.getFullFileUrl.mockResolvedValue('https://files.example.com/user-skill.zip');
+ mocks.findAll.mockResolvedValue({ data: [], total: 0 });
+ mocks.findById.mockResolvedValue(undefined);
+ mocks.findByName.mockImplementation(async (name: string) => {
+ if (name === 'user-skill') {
+ return {
+ id: 'user-skill-id',
+ name: 'user-skill',
+ zipFileHash: 'zip-hash-1',
+ };
+ }
+
+ return undefined;
+ });
+ mocks.getAgentSkills.mockResolvedValue([]);
+ mocks.getUserSettings.mockResolvedValue({ market: { accessToken: 'market-token' } });
+ mocks.sandboxService.callTool.mockResolvedValue({
+ result: {
+ exitCode: 0,
+ output: 'ok',
+ stdout: 'ok',
+ success: true,
+ },
+ success: true,
+ });
+ });
+
+ it('executes scripts through the sandbox service and only attaches persisted skill zips', async () => {
+ const { skillsRuntime } = await import('../skills');
+ const runtime = await skillsRuntime.factory({
+ serverDB: {} as never,
+ toolManifestMap: {},
+ topicId: 'topic-1',
+ userId: 'user-1',
+ });
+
+ const result = await runtime.execScript({
+ activatedSkills: [
+ { id: 'user-skill-id', name: 'user-skill' },
+ { id: 'builtin-skill-id', name: 'builtin-skill' },
+ ],
+ command: 'python scripts/run.py',
+ description: 'Run skill script',
+ });
+
+ expect(result.success).toBe(true);
+ expect(mocks.findByName).toHaveBeenCalledWith('user-skill');
+ expect(mocks.findByName).toHaveBeenCalledWith('builtin-skill');
+ expect(mocks.checkHash).toHaveBeenCalledWith('zip-hash-1');
+ expect(mocks.sandboxService.callTool).toHaveBeenCalledWith(
+ 'execScript',
+ expect.objectContaining({
+ command: 'python scripts/run.py',
+ description: 'Run skill script',
+ skillZipUrls: {
+ 'user-skill': 'https://files.example.com/user-skill.zip',
+ },
+ }),
+ );
+ });
+});
diff --git a/src/server/services/toolExecution/serverRuntimes/cloudSandbox.ts b/src/server/services/toolExecution/serverRuntimes/cloudSandbox.ts
index 5b230b4af4..74e46b18b8 100644
--- a/src/server/services/toolExecution/serverRuntimes/cloudSandbox.ts
+++ b/src/server/services/toolExecution/serverRuntimes/cloudSandbox.ts
@@ -5,7 +5,7 @@ import {
import { FileService } from '@/server/services/file';
import { MarketService } from '@/server/services/market';
-import { ServerSandboxService } from '@/server/services/sandbox';
+import { createSandboxService } from '@/server/services/sandbox';
import { type ServerRuntimeRegistration } from './types';
@@ -25,7 +25,7 @@ export const cloudSandboxRuntime: ServerRuntimeRegistration = {
const marketService = new MarketService({ userInfo: { userId: context.userId } });
const fileService = new FileService(context.serverDB, context.userId);
- const sandboxService = new ServerSandboxService({
+ const sandboxService = createSandboxService({
fileService,
marketService,
topicId: context.topicId,
diff --git a/src/server/services/toolExecution/serverRuntimes/skills.ts b/src/server/services/toolExecution/serverRuntimes/skills.ts
index d854d13ff4..3ecf07ccac 100644
--- a/src/server/services/toolExecution/serverRuntimes/skills.ts
+++ b/src/server/services/toolExecution/serverRuntimes/skills.ts
@@ -2,7 +2,11 @@ import { builtinSkills } from '@lobechat/builtin-skills';
import { LocalSystemApiName, LocalSystemIdentifier } from '@lobechat/builtin-tool-local-system';
// Note: only `readFile` is wired through deviceGateway. Directory enumeration is
// left to the model via `local-system.listFiles` so we don't double-fetch.
-import { type CommandResult, SkillsIdentifier } from '@lobechat/builtin-tool-skills';
+import {
+ type CommandResult,
+ type ExecScriptActivatedSkill,
+ SkillsIdentifier,
+} from '@lobechat/builtin-tool-skills';
import {
type DeviceFileAccess,
type ExportFileResult,
@@ -10,18 +14,16 @@ import {
SkillsExecutionRuntime,
} from '@lobechat/builtin-tool-skills/executionRuntime';
import type { BuiltinSkill, SkillItem, SkillListItem, SkillResourceContent } from '@lobechat/types';
-import type { CodeInterpreterToolName } from '@lobehub/market-sdk';
import debug from 'debug';
-import { sha256 } from 'js-sha256';
import { AgentSkillModel } from '@/database/models/agentSkill';
import { FileModel } from '@/database/models/file';
import { UserModel } from '@/database/models/user';
import { filterBuiltinSkills } from '@/helpers/skillFilters';
-import { FileS3 } from '@/server/modules/S3';
import { AgentDocumentsService } from '@/server/services/agentDocuments';
import { FileService } from '@/server/services/file';
import { MarketService } from '@/server/services/market';
+import { createSandboxService, normalizeSandboxCommandResult } from '@/server/services/sandbox';
import { SkillResourceService } from '@/server/services/skill/resource';
import { preprocessLhCommand } from '@/server/services/toolExecution/preprocessLhCommand';
@@ -30,6 +32,12 @@ import { type ServerRuntimeRegistration } from './types';
const log = debug('lobe-server:skills-runtime');
+interface UserSettingsWithMarketToken {
+ market?: {
+ accessToken?: string;
+ };
+}
+
class SkillServerRuntimeService implements SkillRuntimeService {
private resourceService: SkillResourceService;
private skillModel: AgentSkillModel;
@@ -88,12 +96,13 @@ class SkillServerRuntimeService implements SkillRuntimeService {
}
try {
- const market = this.marketService.market;
- const response = await market.plugins.runBuildInTool(
- 'runCommand' as any,
- { command: lhResult.command },
- { topicId: this.topicId, userId: this.userId },
- );
+ const sandboxService = createSandboxService({
+ fileService: this.fileService,
+ marketService: this.marketService,
+ topicId: this.topicId,
+ userId: this.userId,
+ });
+ const response = await sandboxService.callTool('runCommand', { command: lhResult.command });
log('runCommand response: %O', response);
@@ -106,14 +115,7 @@ class SkillServerRuntimeService implements SkillRuntimeService {
};
}
- const result = response.data?.result || {};
-
- return {
- exitCode: result.exitCode ?? (response.success ? 0 : 1),
- output: result.stdout || result.output || '',
- stderr: result.stderr || '',
- success: response.success && (result.exitCode === 0 || result.exitCode === undefined),
- };
+ return normalizeSandboxCommandResult(response);
} catch (error) {
log('Error running command: %O', error);
return {
@@ -128,69 +130,61 @@ class SkillServerRuntimeService implements SkillRuntimeService {
execScript = async (
command: string,
options: {
- config?: { description?: string; id?: string; name?: string };
+ activatedSkills?: ExecScriptActivatedSkill[];
description: string;
- runInClient?: boolean;
},
): Promise => {
- const { config, description } = options;
+ const { activatedSkills, description } = options;
if (!this.topicId) {
throw new Error('topicId is required for execScript');
}
try {
- // Look up skill zipUrl if config is provided (same logic as market.ts)
- const enhancedParams: any = {
+ const enhancedParams: Record = {
+ activatedSkills,
command,
- config,
description,
};
- if (config?.name) {
- const skill = await this.skillModel.findByName(config.name);
+ if (activatedSkills?.length) {
+ const skillZipUrls: Record = {};
- // If skill not found, return error with available skills
- if (!skill) {
- const allSkills = await this.skillModel.findAll();
- const availableSkills = allSkills.data.map((s) => s.name).join(', ');
+ for (const activatedSkill of activatedSkills) {
+ if (!activatedSkill.name) continue;
- const errorMessage = availableSkills
- ? `Skill "${config.name}" not found. Available skills: ${availableSkills}`
- : `Skill "${config.name}" not found. No skills available. Please import a skill first.`;
+ const skill = await this.skillModel.findByName(activatedSkill.name);
- log('Skill not found: %s. Available skills: %s', config.name, availableSkills);
+ if (!skill) {
+ log('No persisted skill bundle found for activated skill: %s', activatedSkill.name);
+ continue;
+ }
- return {
- exitCode: 1,
- output: '',
- stderr: errorMessage,
- success: false,
- };
+ if (!skill.zipFileHash) continue;
+
+ const fileInfo = await this.fileModel.checkHash(skill.zipFileHash);
+ if (!fileInfo.isExist || !fileInfo.url) continue;
+
+ const fullUrl = await this.fileService.getFullFileUrl(fileInfo.url);
+ if (fullUrl) {
+ skillZipUrls[skill.name] = fullUrl;
+ log('Resolved zipUrl for skill %s', skill.name);
+ }
}
- if (skill.zipFileHash) {
- // Get S3 key from globalFiles
- const fileInfo = await this.fileModel.checkHash(skill.zipFileHash);
-
- if (fileInfo.isExist && fileInfo.url) {
- // Convert S3 key to full URL
- const fullUrl = await this.fileService.getFullFileUrl(fileInfo.url);
- if (fullUrl) {
- enhancedParams.zipUrl = fullUrl;
- log('Added zipUrl to execScript params for skill %s: %s', skill.name, fullUrl);
- }
- }
+ if (Object.keys(skillZipUrls).length > 0) {
+ enhancedParams.skillZipUrls = skillZipUrls;
+ log('Added skillZipUrls to execScript params: %O', Object.keys(skillZipUrls));
}
}
- // Call market-sdk's runBuildInTool
- const market = this.marketService.market;
- const response = await market.plugins.runBuildInTool(
- 'execScript' as CodeInterpreterToolName,
- enhancedParams,
- { topicId: this.topicId, userId: this.userId },
- );
+ const sandboxService = createSandboxService({
+ fileService: this.fileService,
+ marketService: this.marketService,
+ topicId: this.topicId,
+ userId: this.userId,
+ });
+ const response = await sandboxService.callTool('execScript', enhancedParams);
log('execScript response: %O', response);
@@ -203,14 +197,7 @@ class SkillServerRuntimeService implements SkillRuntimeService {
};
}
- const result = response.data?.result || {};
-
- return {
- exitCode: result.exitCode ?? (response.success ? 0 : 1),
- output: result.stdout || result.output || '',
- stderr: result.stderr || '',
- success: response.success && (result.exitCode === 0 || result.exitCode === undefined),
- };
+ return normalizeSandboxCommandResult(response);
} catch (error) {
log('Error executing script: %O', error);
return {
@@ -228,68 +215,21 @@ class SkillServerRuntimeService implements SkillRuntimeService {
}
try {
- const s3 = new FileS3();
-
- // Use date-based sharding (same as market.ts)
- const today = new Date().toISOString().split('T')[0];
- const key = `code-interpreter-exports/${today}/${this.topicId}/${filename}`;
-
- // Step 1: Generate pre-signed upload URL
- const uploadUrl = await s3.createPreSignedUrl(key);
- log('Generated upload URL for key: %s', key);
-
- // Step 2: Call sandbox's exportFile tool with the upload URL
- const market = this.marketService.market;
- const response = await market.plugins.runBuildInTool(
- 'exportFile' as CodeInterpreterToolName,
- { path, uploadUrl },
- { topicId: this.topicId, userId: this.userId },
- );
-
- log('Sandbox exportFile response: %O', response);
-
- if (!response.success) {
- return {
- filename,
- success: false,
- };
- }
-
- const result = response.data?.result;
- const uploadSuccess = result?.success !== false;
-
- if (!uploadSuccess) {
- return {
- filename,
- success: false,
- };
- }
-
- // Step 3: Get file metadata from S3
- const metadata = await s3.getFileMetadata(key);
- const fileSize = metadata.contentLength;
- const mimeType = metadata.contentType || result?.mimeType || 'application/octet-stream';
-
- // Step 4: Create persistent file record
- const fileHash = sha256(key + Date.now().toString());
-
- const { fileId, url } = await this.fileService.createFileRecord({
- fileHash,
- fileType: mimeType,
- name: filename,
- size: fileSize,
- url: key, // Store S3 key
+ const sandboxService = createSandboxService({
+ fileService: this.fileService,
+ marketService: this.marketService,
+ topicId: this.topicId,
+ userId: this.userId,
});
-
- log('Created file record: fileId=%s, url=%s', fileId, url);
+ const result = await sandboxService.exportAndUploadFile(path, filename);
return {
- fileId,
- filename,
- mimeType,
- size: fileSize,
- success: true,
- url, // This is the permanent /f:id URL
+ fileId: result.fileId,
+ filename: result.filename,
+ mimeType: result.mimeType,
+ size: result.size,
+ success: result.success,
+ url: result.url,
};
} catch (error) {
log('Error exporting file: %O', error);
@@ -319,7 +259,8 @@ export const skillsRuntime: ServerRuntimeRegistration = {
try {
const userModel = new UserModel(context.serverDB, context.userId);
const userSettings = await userModel.getUserSettings();
- marketAccessToken = (userSettings?.market as any)?.accessToken;
+ marketAccessToken = (userSettings as UserSettingsWithMarketToken | undefined)?.market
+ ?.accessToken;
log(
'Fetched market accessToken for user %s: %s',
context.userId,