.agents/design/core/ai/sandbox/user-level-sandbox.md
状态:已实现,作为当前分支唯一技术方案
最后核对:2026-07-27
普通 App Chat 的 Sandbox 隔离边界由 appId + effectiveUid + chatId 收敛为
appId + effectiveUid。同一 App、同一有效用户的多个 Chat 共享一个物理 Sandbox 和
Workspace,chatId 只用于区分 sessions/<chatId> 下的默认工作目录。
本方案同时定义用户级 Sandbox 必须依赖的最终契约:
Sandbox 不负责 Agent 模型循环、Workflow 调度或 Skill 版本创建。Agent 和 ToolCall 只在确认本轮
需要且允许使用 Sandbox 后,获取已经准备好的 SandboxClient。
业务归属统一使用 sourceType/sourceId/userId,物理资源使用稳定 sandboxId:
| 场景 | 逻辑身份 | sandboxId |
|---|---|---|
| App Chat | app + appId + effectiveUid | app-<hash(appId-effectiveUid)> |
| Skill Edit | skillEdit + skillId + skillEdit | skilledit-<hash(skillId-skillEdit)> |
| Chat Agent Helper | 不支持 Sandbox | 调用时显式报错 |
其中 hash 取 16 位小写十六进制。App 和 Skill Edit 都不把 chatId 放入实例 ID;不保留旧三参数
ID、无前缀 ID 或空 userId 的运行时兼容分支。
App Sandbox 的路径固定拆分为:
workspaceRoot = <provider workDirectory>
runtimeSkillsRoot = <workspaceRoot>/projects
sessionWorkDirectory = <workspaceRoot>/sessions/<chatId>
sessionWorkDirectory。runtimeSkillsRoot。workspaceRoot 执行,并按物理 Sandbox 记录执行状态。agent_sandbox_instances_v2 是新运行时的唯一实例表。status 是唯一权威生命周期状态。operation 只记录 operation token、持久阶段、心跳和错误,不承担第二套状态判断。legacyMigrating 或其他过渡态实例。sessions/<chatId>。type SandboxInstance = {
provider: 'opensandbox' | 'sealosdevbox';
sandboxId: string;
sourceType: 'app' | 'skillEdit';
sourceId: string;
userId: string;
status:
| 'provisioning'
| 'legacyMigrating'
| 'running'
| 'stopping'
| 'stopped'
| 'archiving'
| 'archived'
| 'restoring'
| 'deleting';
lastActiveAt: Date;
createdAt: Date;
limit?: SandboxLimit;
storage?: SandboxStorage;
teamId?: string;
image?: SandboxImage;
versionId?: string;
operation?: {
id: string;
type: 'provision' | 'legacyMigration' | 'stop' | 'archive' | 'restore' | 'delete';
phase: string;
previousStatus?: 'running' | 'stopped' | 'archived';
startedAt: Date;
heartbeatAt: Date;
failedAt?: Date;
error?: string;
};
};
约束:
(provider, sandboxId) 唯一,约束 Provider 侧物理资源记录。(sourceType, sourceId, userId) 唯一,约束业务逻辑实例。running/stopped/archived,稳定态不得残留 operation。status + operation.heartbeatAt 查询,空闲资源按
status + lastActiveAt 查询。metadata 容器,也不包含 chatId 或旧 appId/type。旧 agent_sandbox_instances 使用独立 Legacy Schema,只允许 migration repository、迁移预检和
Source 删除清理读取。普通 runtime、归档 cron、资源 API 和 Skill Edit 不得回退查询 Legacy 表。
已确认 Legacy 数据不存在 E2B 记录,因此当前 Provider 和迁移范围仅包含 OpenSandbox 与 Sealos Devbox,不保留 E2B adapter 或数据兼容分支。
prepareAgentSandboxRuntime 根据标准 Chat source 生成稳定 ID,并返回 sandboxClient、
workspaceRoot 和当前 workDirectory。完整路径只通过 SandboxClient.getRuntimePaths() 暴露给
文件 API、IDE 和 migration,避免调用方自行拼接 Provider 路径。
App runtime 遵循以下规则:
sessionWorkDirectory 为默认目录。workspaceRoot 内。<sessionWorkDirectory>/user_files。writeFiles 前统一创建目标父目录。runtimeSkillsRoot,不进入 session 目录。.fastgpt/skills/<name>,不进入用户 Workspace、编辑树、
导出包或发布包。workspaceRoot 执行;同一脚本内容按 hash 幂等执行。同一 Sandbox 的 prepare 使用 agent-sandbox:init:<sandboxId> Redis lease 串行化。锁覆盖 session
目录准备、输入文件注入、镜像源、Skill 同步、entrypoint 和 Skill 扫描;锁释放后,后续 Chat 可以
重新调整共享 projects,因此 /projects 不承诺在一次 Agent 执行期间保持不变。
| 操作 | 起始状态 | 过渡态 | 终态 |
|---|---|---|---|
| 首次创建 | 无记录 | provisioning | running |
| Legacy 导入 | 无记录或可接管目标 | legacyMigrating | running |
| 停止 | running | stopping | stopped |
| 归档 | running/stopped | archiving | archived |
| 恢复 | archived | restoring | running |
| 删除 | 可抢占状态 | deleting | 删除记录 |
每次生命周期操作先通过 Mongo CAS 抢占 operation,再执行 Provider、volume 或 S3 副作用;每个
副作用完成后持久化 phase,最后使用相同 operation ID 提交终态。失败保留过渡态、phase 和错误,
由原操作重试或满足隔离窗口后的 stale recovery 接管,不能直接把过渡态改回 running。
锁顺序固定为:
Source Mutation Lease -> Sandbox Lifecycle Lease
sandboxId 为键,跨 Provider 串行化单个物理身份的生命周期。长任务在每个远端副作用前后调用 lease assertValid()。Provider 的 create/start/stop/delete 必须
基于稳定 ID 保持幂等,重复删除或 404 按成功处理。App/Skill source 在创建、恢复、迁移前必须仍然
active;删除任务只处理已经持久标记删除的 source。
sandbox/archive/<sandboxId>/package.zip。agent-sandbox/<legacySandboxId>/package.zip,不能直接改名为 v2 归档。running 后保留 v2 S3 归档,后续重复恢复仍以该归档作为持久备份;只有业务资源
删除流程才清理对应归档。/api/admin/4160/initUserSandbox 内置 beta6 Sandbox 归属归一化作为第 0 阶段,不再依赖已经删除的
/api/admin/4150/init4150-beta6。该阶段补齐 Legacy sourceType/sourceId、清理历史
appId/type/metadata.skillId 字段,并删除无法归属的孤儿 Sandbox 资源。随后按 beta6 原规则
清理缺失 sourceType 的旧 Skill Debug Chat 三表记录和私有、公开 Bucket 旧 S3 前缀;
与 App 同 ID 的 Skill 跳过清理,防止误删 App Chat。dryRun=true 时只统计,不执行写入或删除。
第 0 阶段结束后必须重新统计 Sandbox 待归一化记录和待清理旧 Debug Chat。两者合计为
pendingCount;只要它不为 0,整次任务就停在该阶段,不得归档 Workspace、删除待迁移物理资源
或创建 v2 目标。该总数归零后,先执行一次 Legacy 专属整表预检,再进入 Workspace 迁移;该预检
不复用 v2 instance schema。
Legacy 预检使用独立的 LegacySandboxInstanceZodSchema,不得使用 v2 实例 schema 校验 Legacy
输入。Legacy metadata 可以包含 providerCreatedAt、旧 storage 等 Skill 编辑历史字段;这些字段
由 Legacy schema 读取,在映射到 v2 时显式丢弃。toV2SandboxFields 只能按 v2 稳定根字段白名单
构造结果,避免新的 Legacy 字段通过对象展开泄漏到 v2。
通过归一化屏障后,Workspace 迁移分为全量预归档和安装两个严格阶段:
archiveReady。sourceId + userId 聚合到一个用户级目标;Skill Edit 搬到新的稳定 Skill ID。legacyMigrating + legacyMigration operation,普通 runtime 只能返回忙碌。installed 后,先暂停目标物理 Sandbox,再一次性发布目标为
stopped;暂停失败不得提交迁移完成。completed,保留旧 S3 和 Legacy Mongo 记录作为迁移备份。管理员入口支持可选 skipError=true,用于兼容业务 source 已不存在或已软删除、但 Legacy Sandbox
记录仍残留的升级场景。该开关只跳过 source fence 返回 Sandbox source is missing or deleted 的
整个 source 分组,被跳过的记录不执行归档、资源删除、目标创建或安装,并通过 skippedCount/skipped
单独返回。对象存储、Provider、Lease、归档和安装等其他错误仍计入 failedCount/failures 并维持
全局归档屏障;省略该参数或传 false 时保持原有严格行为。
第一阶段释放单个 Source Lease 后,正常用户请求可以先创建确定性的 v2 目标。第二阶段必须接管或 复用该目标,并按“目标内容优先”规则合并,不能覆盖已经产生的用户文件。
sessions/<legacyChatId>。projects/ 下、名称为 24 位十六进制 Version ID 的运行时缓存目录不迁移。installed 阶段为准。completed 是 Legacy 迁移终态,后续迁移只预检、不重复安装。旧 Skill Debug Chat 清理沿用 beta6 初始化脚本:扫描当前全部 Skill,排除同 ID 的 App 后逐个
统计 Legacy Chat;列表包含空 Skill 的检查结果,但正式执行只删除 chatCount > 0 的 Skill。
matchedSkillCount 表示排除冲突后的扫描数量,cleanedSkillCount 表示实际提交删除的 Skill
数量,pendingChatCount 用于迁移阻塞判断。
Skill 分组并发度为 20;App 分组并发度为 5,组内按 lastActiveAt 从新到旧串行安装。
App Chat 和 Workflow 只在 Agent 或 ToolCall 节点确定本轮实际使用 Sandbox 后,比较目标 Provider
以及目标镜像的 repository + tag:
upgrading -> lazyInit 粗粒度状态,不弹窗、
不重放用户请求;失败按标准 Workflow 错误终止当前节点。迁移过程不新增数据库状态;它复用 archive、restore 和稳定 archived 状态。历史记录缺少
image 时按镜像不一致处理。
普通 App Chat 使用三种稳定不可用原因:
systemDisabled:系统未配置或已下架 Sandbox。appDisabled:当前 App Agent/ToolCall 未开启 Sandbox。teamPlanUnavailable:应用团队套餐不提供 Sandbox,套餐查询失败也按该原因降级。不可用时不注入 Sandbox system prompt、Sandbox tools 或依赖 Sandbox 的 Skill,不准备 runtime、 不执行 entrypoint,也不把关闭状态写成 Agent/ToolCall 错误;其他模型、工具、知识库和 Workflow 节点继续运行。Skill Edit 和 Skill 调试仍是 Sandbox 强依赖,保持结构化错误阻断。
checkExist 同时返回真实本地实例存在性和可选 unavailableReason,查询本身不能创建或恢复实例。
页面加载与对话期间不主动提示;只有用户点击现有虚拟机入口时刷新状态并显示统一 Toast。Ticket、
上传、下载和预览 API 仍在服务端重新校验可用性,不能依赖前端守卫。
HTML 预览和 sandbox_get_file_url 不再把文件上传到 S3,而是签发短期只读 URL:
<previewProxy>/preview/<sandboxId>/<sessionId>/<workspaceRelativePath>
最终链路为:
FastGPT 创建 Redis preview session
-> agent-sandbox-proxy 校验 session 并向 FastGPT 解析 Provider endpoint
-> fastgpt-ide-agent:1319 在 FASTGPT_WORKDIR 内流式读取文件
关键约束:
sandboxId 必须匹配 app|skilledit-<16 hex>;随机 sessionId 为 24 位字母数字字符串。GET、HEAD、ETag 和单段 Range;禁止目录列表、路径穿越和逃逸 Workspace 的软链接。no-referrer、nosniff 和 private, no-store;公开预览 origin 必须与 FastGPT App
origin 隔离。./assets/... 等相对路径;/assets/... 根路径不保留 preview URL 前缀。FastGPT、proxy 和包含 1319 preview listener 的 runtime image 必须协调发布,不支持新旧版本混合 滚动兼容。
stop() 删除远端计算实例,不调用 pause;它不删除 FastGPT 管理的 volume、Mongo
记录或 S3 归档。后续使用相同业务 sandboxId 创建新远端实例并重新挂载原 volume。stop() 继续调用 pause,因此公共 stop() 只表示“执行 Provider 停止策略”,
不承诺复用同一个远端实例。Sandbox.kill() 删除,cron 的未绑定 adapter 通过
SandboxManager.killSandbox() 删除;两条路径都等待远端消失并保持幂等。close() 只释放本地 transport,不改变远端生命周期。Sandbox 模块保持单向依赖:
interface -> application -> infrastructure -> sandbox-adapter
interface/* 使用稳定能力。infrastructure/instance repository。service/workspace/cleanup/normalization/debugChatCleanup/types 拆分,阶段判断留在 application。index.ts,不保留非 index.ts 的兼容转发文件。具体入口和当前工具集合见 Agent Sandbox 当前设计。
用户级 Sandbox 改动至少覆盖:
initUserSandbox 并在剩余待处理数归零前阻断归档。initUserSandbox 增加可选 skipError,仅跳过 source 已缺失的 Legacy Sandbox 分组并返回跳过明细。