diff --git a/.env.example b/.env.example index eff7c31..7155491 100644 --- a/.env.example +++ b/.env.example @@ -1,50 +1,78 @@ -# ============================================================ -# .env.example - 环境变量模板 -# 复制为 .env.local 后填入实际值,或直接运行 ./init.sh 自动生成 -# ============================================================ +# ============================================================================= +# OpenVibeCoding — environment variable reference (DO NOT put secrets here) +# ============================================================================= +# +# Generated artifacts (gitignored): +# .env.local — local dev only (`pnpm dev:server` --env-file=../../.env.local) +# .env.cloud — cloud deploy + runtime (`pnpm deploy:cloud` reads this file; API sync to service) +# +# Run `./init.sh` twice if needed: choose 1) .env.local OR 2) .env.cloud (one file per run). +# +# Monorepo note: frontend (Vite) and backend (Hono) share ONE Node env at runtime. +# - Local: server :3001, web :5174 proxies /api → no second secret file for web +# - Cloud: single container serves API + static build; only .env.cloud matters there +# +# Two encryption keys (both server-only, never VITE_*): +# JWE_SECRET — session cookies (base64, 32 random bytes) +# ENCRYPTION_KEY — MCP connector secrets at rest (hex, 64 chars) +# ============================================================================= + +# ==================== Session / encryption ==================== -# ==================== Required ==================== - -# Session 加密密钥(init.sh 会自动生成) JWE_SECRET= ENCRYPTION_KEY= +# ==================== Local vs cloud server ==================== +# | Variable | .env.local | .env.cloud | +# |-------------------|-------------------|-------------------| +# | PORT | 3001 | 80 | +# | NODE_ENV | development | production | +# | ASK_USER_BASE_URL | http://127.0.0.1:3001 | 公网根 URL;init 可占位,deploy:cloud 首次成功后写回 | + +PORT=3001 +NODE_ENV=development +ASK_USER_BASE_URL=http://127.0.0.1:3001 + +DATABASE_PATH=.data/app.db +DB_PROVIDER=cloudbase +DB_COLLECTION_PREFIX=vibe_agent_ + +MAX_SANDBOX_DURATION=300 +WORKSPACE_ISOLATION=shared +SANDBOX_TTL_SECONDS=1800 + # ==================== Auth ==================== -# 认证方式:local / github / cloudbase(逗号分隔可多选) NEXT_PUBLIC_AUTH_PROVIDERS=local +AUTH_GITHUB_MODE=direct -# ==================== CloudBase ==================== +# ==================== CloudBase platform ==================== -# 腾讯云 API 密钥(访问管理 → API 密钥管理) TCB_SECRET_ID= TCB_SECRET_KEY= +TCB_ENV_ID= +TCB_REGION=ap-shanghai +TCB_PROVISION_MODE=shared -# CloudBase 环境 ID -# TCB_ENV_ID= - -# 区域,默认 ap-shanghai -# TCB_REGION=ap-shanghai +# ==================== CodeBuddy(Copilot 登录) ==================== -# 用户环境模式:shared(默认)或 isolated -# TCB_PROVISION_MODE=shared +# CODEBUDDY_API_KEY= -# ==================== CodeBuddy Auth ==================== +# ==================== CloudBase AI+(自定义模型列表 / 模型调用) ==================== +# 与 CODEBUDDY_API_KEY 不同;init 在配置 models.json / opencode.json 前收集一次 -# 方式一:API Key(推荐,个人用户可直接使用) -# 获取地址:https://copilot.tencent.com/profile/ -# CODEBUDDY_API_KEY= +# CLOUDBASE_API_KEY= +# CODEBUDDY_USE_CUSTOM_MODELS=true # CODEBUDDY_INTERNET_ENVIRONMENT=internal - -# 方式二:OAuth(企业旗舰版) # CODEBUDDY_CLIENT_ID= # CODEBUDDY_CLIENT_SECRET= -# CODEBUDDY_OAUTH_ENDPOINT=https://copilot.tencent.com/oauth2/token -# ==================== Rate Limiting ==================== +# ==================== Stateful sandbox ==================== -MAX_MESSAGES_PER_DAY=50 -MAX_SANDBOX_DURATION=300 +TCB_API_KEY= +# ENABLE_AUTH_MODE=false +# TCB_ACCESS_TOKEN= +# STATEFUL_SANDBOX_IMAGE= # ==================== OpenCode Runtime (Optional) ==================== @@ -56,50 +84,25 @@ MAX_SANDBOX_DURATION=300 # OPENCODE_BIN=/absolute/path/to/coding-agent-template/node_modules/.bin/opencode # OPENCODE_BIN= -# ==================== Sandbox ==================== - -# 镜像类型:personal(默认)或 enterprise -# SANDBOX_IMAGE_TYPE=personal -# SANDBOX_IMAGE_URI= -# 企业版镜像需要 RegistryId,通常由 setup:tcr 自动写入 -# SANDBOX_IMAGE_REGISTRY_ID= -# SANDBOX_IMAGE_PORT=9000 - -# 工作空间隔离模式: -# shared - 同一 envId 下所有 task 共享 SCF 容器实例,通过目录隔离工作区 -# isolated - 每个 task 独立 SCF session,完全隔离文件系统(默认) -WORKSPACE_ISOLATION=isolated - # Vite 原生错误 overlay 开关(创建沙箱时注入,需要重建沙箱生效) -# 设为 false 可关闭预览中 Vite 自带的全屏错误遮罩,改由平台侧 banner 展示构建错误 # VITE_DEV_OVERLAY=false -# ==================== TCR ==================== +# ==================== TCR (optional, setup-tcr.mjs) ==================== -# TCR 容器镜像配置(由 pnpm setup:tcr 自动写入) -# TCR_EDITION=personal +# TCR_EDITION=personal | enterprise # TCR_NAMESPACE= # TCR_PASSWORD= # TCR_IMAGE= - -# TCR 企业版配置(仅 TCR_EDITION=enterprise 时需要) # TCR_DOMAIN=example.tencentcloudcr.com # TCR_REGION=ap-guangzhou # TCR_DOCKER_USERNAME= # TCR_TOKEN_ID= -# ==================== GitHub OAuth (Optional) ==================== +# ==================== Optional ==================== # GITHUB_CLIENT_ID= # GITHUB_CLIENT_SECRET= - -# ==================== Git Archive (Optional) ==================== - -# 工作区 Git 归档配置 # GIT_ARCHIVE_REPO= # GIT_ARCHIVE_USER= # GIT_ARCHIVE_TOKEN= - -# ==================== Proxy (Optional) ==================== - # http_proxy= diff --git a/.gitignore b/.gitignore index 2ae152f..b09548f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,7 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files +# env files — only root .env.example is committed; all secrets live in .env.local / .env.cloud .env* !.env.example @@ -70,6 +70,9 @@ data/*.db-wal packages/server/data/*.db packages/server/data/*.db-shm packages/server/data/*.db-wal +packages/server/*.db +packages/server/*.db-shm +packages/server/*.db-wal packages/server/.data/*.db packages/server/.data/*.db-shm packages/server/.data/*.db-wal @@ -90,6 +93,16 @@ CodeBuddy Code_decompiled CodeBuddy Code_files decompiled decompiled-ui +.plans/ +.cursor/ + +# CocoIndex Code (ccc) +.cocoindex_code/ +/.cocoindex_code/ + +# CodeGraph +.codegraph/ + # opencode project-level config (auto-generated tool overrides) .opencode/tools/ .opencode/opencode.json diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/AGENTS.md b/AGENTS.md index d5fd7d0..e89176e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,7 @@ SSE poll 检测 isDone → 发 [DONE] → removeAgent() - 沙箱 = SCF 容器(基于自定义 Docker 镜像) - 工具重定向:CLI 的文件/命令工具通过 HTTP API 路由到沙箱 - MCP Proxy:CloudBase 工具通过 sandbox 内的 mcporter 发现和执行 -- 隔离模式:`WORKSPACE_ISOLATION=isolated`(每 task 独立)/ `shared`(共享 session) +- 沙箱实例模式:`WORKSPACE_ISOLATION=shared`(默认,每 env 一实例)/ `isolated`(每 task 一实例) ### 数据库 @@ -151,7 +151,7 @@ releaseEnv() → 销毁 CAM + 环境资源 ## 调试 - `AGENT_DEBUG_JSONL=1` — 开启完整消息日志(写入 `debug-jsonl/` 目录) -- `packages/server/.env` 中的 `NODE_ENV=development` — 开发模式详细错误 +- `.env.local` 中的 `NODE_ENV=development` — 开发模式详细错误 - Agent Registry 日志前缀:`[Registry]` - SSE Poll 日志前缀:`[SSE poll]` - 沙箱日志前缀:`[sandbox]` diff --git a/CHANGELOG.md b/CHANGELOG.md index b3ddfc0..348c882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ All notable changes to this project will be documented in this file. ### Added +- **沙箱 infra(Stateful + 沙箱业务镜像)**:`StatefulSandboxProvider` + `ensureStatefulTool` + 沙箱业务镜像 数据面;移除 SCF `scf-sandbox-manager` / `sandbox-mcp-proxy`;预览经 OpenVibeCoding → gateway → 沙箱业务镜像 `/preview/5173/`;终端 ttyd `/preview/7681/`;镜像更新后 `stateful-tool-warmup` 轮询 + +### Changed + +- **沙箱子工作区 Scope API 已移除**(旧 SCF 多端口 5173–5199);工作区统一 `/home/user` + - **CodeBuddy 自定义模型支持**:`CODEBUDDY_USE_CUSTOM_MODELS=true` 时按 `.config/.codebuddy/models.json` 模板加载模型列表,前端 listModels / SDK modelId 校验都走自定义白名单;`false` 时保留 SYSTEM_MODELS 写死的官方列表 - **环境隔离粒度配置**(Issue #14):admin 可在 `/admin/settings` 切换 `shared` / `isolated` / `task` 三种 provision mode,DB 优先级高于 env 默认,配 source badge 与重置按钮 - **环境池(Environment Pool)**:预创建 CloudBase 环境 + CAM + Policy,task/isolated 模式获取环境从分钟级降到毫秒级 diff --git a/Dockerfile b/Dockerfile index 742d135..b2fdb84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,10 @@ FROM node:22-slim AS build WORKDIR /app +# git: install-skills.sh clones cloudbase-skills via npx +RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + # Enable corepack for pnpm RUN corepack enable && corepack prepare pnpm@latest --activate diff --git a/README-zh.md b/README-zh.md index bdc5a2b..97d68e1 100644 --- a/README-zh.md +++ b/README-zh.md @@ -47,7 +47,7 @@ | 基础设施 | 绑定特定平台 | 腾讯云 CloudBase(DB / Storage / Functions / CDN) | | Agent 引擎 | 内置单一模型 | CodeBuddy + OpenCode 双引擎,模型自由切换 | | 环境隔离 | 用户级隔离 | shared / isolated / task 三级隔离,支持多租户 | -| 沙箱 | 平台托管 | CloudBase SCF + TCR 容器镜像,可自定义运行时 | +| 沙箱 | 平台托管 | CloudBase AGS Stateful + 沙箱业务镜像(TCR 镜像),gateway 数据面 | | 云资源操作 | 无 / 有限 | MCP 工具直接操作 DB、存储、函数、域名 | | 部署目标 | 平台内托管 | Web CDN / 微信小程序 / 自定义域名 | | 人机协作 | 基础对话 | Plan 模式 + ToolConfirm 四值权限 + 内联提问表单 | @@ -62,9 +62,8 @@ | **双 Agent 引擎** | CodeBuddy 与 OpenCode 可选,各自独立模型列表,前端一键切换 | | **三级环境隔离** | shared(共用)/ isolated(用户独立)/ task(独立子账号),Admin 后台热切换,无需重启 | | **环境池预热** | 预创建 CloudBase 环境 + CAM + Policy,获取延迟从分钟级降至毫秒级;池空时自动回退实时创建 | -| **编码沙箱** | SCF 容器冷启动 → PTY 终端 → Vite Dev Server 端口动态分配;进度细分到镜像拉取、容器就绪、工作区初始化 | +| **编码沙箱** | AGS Tool/实例;沙箱业务镜像 工作区 `/home/user`;预览 `/preview/5173`、终端 `/preview/7681` 经 OpenVibeCoding 反代 | | **实时预览** | 内嵌 Browser 工具栏(地址栏 / 导航 / 刷新);HMR 热更新;预览错误自动修复反馈 | -| **子工作区** | 同一 session 内多个隔离 Scope,独立 dev server,端口 5173–5199 动态分配 | | **CloudBase MCP** | 50+ 工具覆盖 DB、Storage、Functions、域名、安全规则,Agent 可直接操作云资源 | | **Human-in-Loop** | 工具执行四值确认(allow / always / deny / exit);内联提问表单,不打断对话上下文 | | **Plan 模式** | 写操作自动拦截;三按钮决策(执行 / 完善 / 拒绝退出);跨组件状态共享 | @@ -117,72 +116,100 @@ ## 快速开始 -**前置条件** +环境文件 **刻意分开**,避免把本地密钥打进云镜像、或把云配置误用于 `pnpm dev`: -- Node.js >= 18 -- Docker -- 腾讯云账号(CloudBase 环境 + API 密钥) -- CodeBuddy API Key 或 OAuth 配置 +| | 本地开发 | 部署到云托管 | +| --- | --- | --- | +| **初始化** | `./init.sh` → 选 **1** → `.env.local` | `./init.sh` → 选 **2** → `.env.cloud` | +| **命令** | `pnpm dev` | `pnpm deploy:cloud` | +| **访问** | Web `http://localhost:5174`,API `:3001` | 默认域名 `https://*.sh.run.tcloudbase.com` | +| **端口** | `PORT=3001` | 容器 **80**(勿改成 9000;9000 是沙箱 TRW 内部口) | +| **详解** | 下文 [本地开发](#本地开发) | [docs/cloudrun-deploy.md](docs/cloudrun-deploy.md) | -**一键初始化** +两条线都要:先 `./init.sh` 选 1,再 `./init.sh` 选 2。字段模板见 [.env.example](.env.example)。 ```bash git clone https://github.com/TencentCloudBase/OpenVibeCoding.git cd OpenVibeCoding +./init.sh # 按上表选 1 或 2 +pnpm dev # 本地 +# 或 +pnpm deploy:cloud # 云托管(需 cloudbase CLI) +``` + +--- -# macOS / Linux / Git Bash / WSL -./init.sh +## 初始化 -# Windows(需先确认已装 Node.js >= 18 和 pnpm) -node scripts/init.mjs -``` +`./init.sh` 检查 Node / pnpm 后执行 `scripts/init.mjs`。每次 **二选一**(不会一次生成两份 env): -初始化脚本依次完成:Node.js 检查 → pnpm 安装 → `.env.local` 生成 → Docker 检查 → CloudBase 配置 → 依赖安装 → CodeBuddy 认证 → TCR 配置 → 数据库初始化。 +| 选项 | 生成文件 | 用途 | +| --- | --- | --- | +| **1** | `.env.local` | `pnpm dev` / `pnpm dev:server`(`--env-file=../../.env.local`) | +| **2** | `.env.cloud` | `pnpm deploy:cloud`(CLI 凭证 + 尝试同步到云托管运行时) | -详细步骤与排障见 [docs/setup.md](docs/setup.md)。 +完整流程与排障:[docs/setup.md](docs/setup.md#配置文件职责)。 --- -## 开发 +## 本地开发 -```bash -pnpm dev # 同时启动 web (localhost:5174) 和 server (localhost:3001) -pnpm dev:web # 仅启动前端 -pnpm dev:server # 仅启动后端 -``` +**前置**:Node **22.x**(`.nvmrc`)、pnpm 10+、CloudBase 支撑环境 + API 密钥、CodeBuddy 或 OpenCode(`npm i -g opencode-ai`)。本机只跑 OpenVibeCoding 前后端;**编码沙箱在云端**(Stateful + 沙箱业务镜像),不在本机 Docker 里跑任务。 -## 生产 +**环境**:只读根目录 **`.env.local`**。Vite 将 `/api` 代理到 `:3001`,前端无需单独 env。 + +**Agent**:任务可选 **CodeBuddy** 或 **OpenCode**(`opencode-acp`),共用同一套云沙箱。 ```bash -pnpm build # 构建所有包 -pnpm start # 启动生产服务(端口 3001,同时服务 API 和静态文件) +pnpm dev # Web :5174 + API :3001 +pnpm dev:web # 仅前端 +pnpm dev:server # 仅后端 +pnpm build && pnpm start # 本机一体启动(仍读本机环境,≠ 云托管) ``` +| 变量(`.env.local`) | 作用 | +| --- | --- | +| `JWE_SECRET` / `ENCRYPTION_KEY` | 会话与 MCP 密文 | +| `TCB_ENV_ID` / `TCB_SECRET_*` | CloudBase 管理面 | +| `TCB_API_KEY` | 沙箱数据面 gateway | +| `CODEBUDDY_API_KEY` | CodeBuddy Agent | + +Tool 名 `openvibecoding-{TCB_ENV_ID}` 自动创建/复用,**勿**配置 `STATEFUL_TOOL_ID`。排障见 [docs/setup.md](docs/setup.md)。 + +--- + ## 部署到云托管 -本项目支持一键部署到 CloudBase 云托管(容器服务)。无需本地 Docker —— 脚本会将源码和 Dockerfile 提交到云端构建。 +与本地 **完全分开**:不要在本机 `pnpm dev` 的同时指望 `.env.cloud` 生效;部署读 **`.env.cloud`**,用 CloudBase CLI 上传源码,在云端按 `Dockerfile` 构建镜像。 -**前置条件** +**前置** -- 已完成 `./init.sh` 初始化(`TCB_ENV_ID`、`TCB_SECRET_ID`、`TCB_SECRET_KEY` 已配置) -- 已安装 CloudBase CLI:`npm i -g @cloudbase/cli` +- `./init.sh` 选 **2**,生成 `.env.cloud`(含 `TCB_SECRET_*`、`TCB_ENV_ID`) +- `npm i -g @cloudbase/cli` 且 `cloudbase login` +- `ASK_USER_BASE_URL` 可先占位;部署脚本会从默认域名(如 `https://vibecoding-platform-xxx.sh.run.tcloudbase.com`)**写回** `.env.cloud` -**一键部署** +**一键部署**(推荐在**本机终端**执行,避免 IDE 会话超时中断上传): ```bash pnpm deploy:cloud ``` -脚本会自动执行: -1. 提交源码 + Dockerfile 到云端构建镜像 -2. 部署为云托管容器服务(服务名:`vibecoding-platform`,端口:80) -3. 查询并输出服务的访问地址 +脚本会: + +1. **立即**打印云托管控制台链接(部署记录 / 构建日志) +2. 提交源码(上传阶段无百分比,约每 15s 一行心跳提示) +3. **轮询**服务/部署记录状态,直到本次发布生效或失败 +4. 若 `ASK_USER_BASE_URL` 仍为占位,用 `*.sh.run.tcloudbase.com` 写回 `.env.cloud` +5. 尝试把 `.env.cloud` 同步到云托管环境变量(失败则提示控制台手贴) -**部署完成后** +| 标志 | 含义 | +| --- | --- | +| `--no-wait` | 只提交构建,不轮询(CI / 自行看控制台) | +| `--skip-env-sync` | 不同步环境变量到云托管 | -- 访问地址格式:`https://{serviceName}-{id}.{region}.run.tcloudbase.com` -- 构建进度可在 [云开发控制台](https://tcb.cloud.tencent.com) → 云托管 → 服务详情 → 部署记录 中查看 -- 环境变量需在控制台的服务配置中手动设置(或后续版本支持自动注入) +服务名 **`vibecoding-platform`**,对外端口 **80**。沙箱 gateway 的 **9000** 与云托管监听端口无关。 + +详情:[docs/cloudrun-deploy.md](docs/cloudrun-deploy.md)。 ## 常用命令 @@ -213,7 +240,9 @@ pnpm opencode:setup # 配置 OpenCode provider 和模型 ├── docs/ │ ├── setup.md # setup 详解与排障 │ ├── architecture.md # 系统架构文档 -│ └── scf-session-sharing.md # SCF Session 共享设计 +│ ├── cloudrun-deploy.md # 云托管部署 +│ ├── upstream-fork.md # 上游分叉与同步 +│ └── scf-session-sharing.md # (历史)SCF ├── packages/ │ ├── web/ # React 19 + Vite 前端 │ ├── server/ # Hono 后端:Auth、Agent 编排、Sandbox 管理 @@ -221,6 +250,7 @@ pnpm opencode:setup # 配置 OpenCode provider 和模型 │ └── shared/ # ACP 协议类型、任务 / 消息 schema ├── scripts/ │ ├── init.mjs # 交互式初始化脚本 +│ ├── deploy.mjs # 云托管一键部署 │ └── setup-tcr.mjs # TCR 镜像仓库配置 └── init.sh # 快速入口 ``` @@ -235,7 +265,7 @@ pnpm opencode:setup # 配置 OpenCode provider 和模型 | 后端 | Hono, Node.js, Drizzle ORM | | 数据库 | CloudBase DB(主),SQLite(本地回退) | | AI | `@tencent-ai/agent-sdk` (CodeBuddy), OpenCode ACP | -| Sandbox | CloudBase SCF, TCR 容器镜像 | +| Sandbox | CloudBase AGS Stateful + 沙箱业务镜像,TCR 镜像 | | 认证 | JWE session, bcrypt, Arctic (OAuth) | | 持久化 | CloudBase DB, 本地 .jsonl, Git archive | | 协议 | ACP (JSON-RPC 2.0 + SSE), MCP (Model Context Protocol) | @@ -246,36 +276,11 @@ pnpm opencode:setup # 配置 OpenCode provider 和模型 ## 环境变量 -完整变量说明见 [docs/setup.md](docs/setup.md)。核心变量: - -```env -# 加密密钥(init 脚本自动生成) -JWE_SECRET= -ENCRYPTION_KEY= - -# 认证 -NEXT_PUBLIC_AUTH_PROVIDERS=local # local | github | cloudbase - -# CloudBase -TCB_SECRET_ID= -TCB_SECRET_KEY= -TENCENTCLOUD_ACCOUNT_ID= -TCB_ENV_ID= -TCB_PROVISION_MODE=shared # shared | isolated | task - -# TCR -TCR_NAMESPACE= -TCR_PASSWORD= -TCR_IMAGE= - -# 可选 -MAX_MESSAGES_PER_DAY=50 -MAX_SANDBOX_DURATION=300 -ANTHROPIC_API_KEY= -OPENAI_API_KEY= -GEMINI_API_KEY= -GIT_PERSONAL_AUTH= -``` +- 模板(可提交):[.env.example](.env.example) +- 本地产物:`.env.local` +- 云托管产物:`.env.cloud`(与 local 大部分相同;`PORT=80`、`NODE_ENV=production`、`ASK_USER_BASE_URL` 为公网 URL) + +完整说明:[docs/setup.md](docs/setup.md) --- @@ -303,7 +308,7 @@ pnpm opencode:setup 1. 调用腾讯云开发 AI+ 接口 [DescribeAIModels](https://cloud.tencent.com/document/product/876/131318) 拉取模型 2. 引导并配置腾讯云开发 API Key 3. 从 catalog 取完整配置写入 `.opencode/opencode.json`(含 npm/baseURL/models 等) -4. 把 API Key 写入 `packages/server/.env` +4. 把 API Key 写入 `.env.local` ### 生成结果示例 @@ -329,7 +334,7 @@ pnpm opencode:setup ``` ```bash -# packages/server/.env 会追加 API Key +# .env.local 会追加 API Key CLOUDBASE_API_KEY=eyJhbGciOiJS.xxxxxxxx ``` @@ -373,7 +378,7 @@ pnpm codebuddy:setup 该命令会: 1. 调用腾讯云开发 AI+ 接口 [DescribeAIModels](https://cloud.tencent.com/document/product/876/131318) 拉取当前环境已开通的模型 -2. 检查 `CLOUDBASE_API_KEY`,缺失时引导输入并自动写入 `packages/server/.env` +2. 检查 `CLOUDBASE_API_KEY`,缺失时引导输入并自动写入 `.env.local` 3. 同时设置 `CODEBUDDY_USE_CUSTOM_MODELS=true` 4. 生成 `packages/server/.config/.codebuddy/models.json` 供 SDK 读取 @@ -398,7 +403,7 @@ pnpm codebuddy:setup ``` ```bash -# packages/server/.env 会自动追加 +# .env.local 会自动追加 CLOUDBASE_API_KEY=eyJhbGciOiJS.xxxxxxxx CODEBUDDY_USE_CUSTOM_MODELS=true ``` @@ -435,7 +440,7 @@ packages/server/.config/.codebuddy/models.json } ``` -同时确保在 `packages/server/.env` 中提供对应的环境变量,并设置: +同时确保在 `.env.local` 中提供对应的环境变量,并设置: ```bash CODEBUDDY_USE_CUSTOM_MODELS=true @@ -445,9 +450,10 @@ CODEBUDDY_USE_CUSTOM_MODELS=true ## 延伸阅读 -- [Setup 指南](docs/setup.md) — 初始化流程、环境变量、验证清单与排障 -- [系统架构](docs/architecture.md) — 系统分层、模块设计与关键数据流 -- [SCF Session 共享设计](docs/scf-session-sharing.md) — 沙箱 session 复用机制 +- [Setup 指南](docs/setup.md) — 初始化、本地开发、环境变量与排障 +- [云托管部署](docs/cloudrun-deploy.md) — `pnpm deploy:cloud` 行为与控制台对照 +- [系统架构](docs/architecture.md) — 分层、模块与数据流 +- [SCF Session 共享设计](docs/scf-session-sharing.md) — (历史)沙箱 session 复用 --- diff --git a/README.md b/README.md index 7a416ee..31255cf 100644 --- a/README.md +++ b/README.md @@ -40,42 +40,42 @@ An open-source alternative to [Lovable](https://lovable.dev) / [v0](https://v0.d ## Why this project -| | Lovable / v0 / bolt.new | This project | -| --------------------- | -------------------------- | ----------------------------------------------------------------- | -| Source code | Closed-source SaaS | Fully open-source (Apache 2.0), self-hostable | -| Pricing | Usage-based / subscription | Bring your own cloud resources, cost-controllable | -| Infrastructure | Vendor-locked | Tencent CloudBase (DB / Storage / Functions / CDN) | -| Agent engine | Single built-in model | CodeBuddy + OpenCode dual engines, free model switching | -| Environment isolation | User-level only | shared / isolated / task three-tier isolation, multi-tenant ready | -| Sandbox | Platform-managed | CloudBase SCF + TCR container images, customizable runtime | -| Cloud resource ops | None / limited | MCP tools operate DB, Storage, Functions, domains directly | -| Deploy targets | Platform-hosted only | Web CDN / WeChat Mini Program / custom domains | -| Human-in-the-loop | Basic chat | Plan mode + four-value ToolConfirm + inline AskUser form | -| Extensibility | Not extensible | Monorepo, decoupled frontend/backend, fork-friendly | +| | Lovable / v0 / bolt.new | This project | +| --------------------- | ------------------------- | ------------------------------------------------------------------------- | +| Source code | Closed-source SaaS | Fully open-source (Apache 2.0), self-hostable | +| Pricing | Usage-based / subscription| Bring your own cloud resources, cost-controllable | +| Infrastructure | Vendor-locked | Tencent CloudBase (DB / Storage / Functions / CDN) | +| Agent engine | Single built-in model | CodeBuddy + OpenCode dual engines, free model switching | +| Environment isolation | User-level only | shared / isolated / task three-tier isolation, multi-tenant ready | +| Sandbox | Platform-managed | CloudBase AGS Stateful + 沙箱业务镜像 (TCR images), gateway data plane | +| Cloud resource ops | None / limited | MCP tools operate DB, Storage, Functions, domains directly | +| Deploy targets | Platform-hosted only | Web CDN / WeChat Mini Program / custom domains | +| Human-in-the-loop | Basic chat | Plan mode + four-value ToolConfirm + inline AskUser form | +| Extensibility | Not extensible | Monorepo, decoupled frontend/backend, fork-friendly | --- ## Feature highlights -| Capability | Highlights | -| ------------------------ | -------------------------------------------------------------------------------------------------------------------- | -| **Dual Agent engines** | Choose between CodeBuddy and OpenCode, each with its own model list, one-click switch from the UI | -| **Three-tier isolation** | shared / isolated (per user) / task (per-task subaccount), hot-switchable from Admin without restart | -| **Environment pool** | Pre-created CloudBase env + CAM + Policy; acquisition latency drops from minutes to milliseconds; fallback on miss | -| **Coding sandbox** | SCF container cold start → PTY terminal → Vite dev server with dynamic port; progress split into pull / ready / init | -| **Live preview** | Embedded browser toolbar (address bar / nav / refresh); HMR; auto-feedback loop on preview errors | -| **Sub-workspaces** | Multiple isolated scopes per session, independent dev servers, ports 5173–5199 dynamically allocated | -| **CloudBase MCP** | 50+ tools covering DB, Storage, Functions, domains, security rules — Agent operates cloud resources directly | -| **Human-in-Loop** | Four-value tool confirmation (allow / always / deny / exit); inline AskUser form without breaking chat context | -| **Plan mode** | Auto-intercepts write operations; three-button decision (execute / refine / reject); cross-component state sharing | -| **Tool rendering** | 10 dedicated renderers (Bash / Read / Write / Edit / Grep / Glob, etc.); Edit ships with built-in git-diff view | -| **One-click deploy** | Web static hosting → CDN; async WeChat Mini Program deploy; unified artifact aggregated in Deployments tab | -| **Image generation** | AI-generated images auto-uploaded to CloudBase hosting; CDN URL returned; rendered inline as Markdown | -| **Git archive** | Auto-push to remote on task end; branch by envId + directory by conversationId; in-memory credentials, no token leak | -| **Resource dashboard** | Embedded DB / Storage / SQL / Functions management inside the task detail page | -| **Admin console** | User management, env pool monitoring, provision mode config, audit logs | -| **Scheduled tasks** | Cron scheduling + distributed lock to prevent re-entry | -| **Credential security** | AES-256-CBC encrypted storage; STS scoped temporary credentials; logs restricted to static strings only | +| Capability | Highlights | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| **Dual Agent engines** | Choose between CodeBuddy and OpenCode, each with its own model list, one-click switch from the UI | +| **Three-tier isolation** | shared / isolated (per user) / task (per-task subaccount), hot-switchable from Admin without restart | +| **Environment pool** | Pre-created CloudBase env + CAM + Policy; acquisition latency drops from minutes to milliseconds; fallback on miss | +| **Coding sandbox** | AGS tool/instance per env; 沙箱业务镜像 at `/home/user`; preview `/preview/5173`, terminal ttyd `/preview/7681` via OpenVibeCoding proxy | +| **Live preview** | Embedded browser toolbar (address bar / nav / refresh); HMR; auto-feedback loop on preview errors | +| **Sub-workspaces** | Multiple isolated scopes per session, independent dev servers, ports 5173–5199 dynamically allocated | +| **CloudBase MCP** | 50+ tools covering DB, Storage, Functions, domains, security rules — Agent operates cloud resources directly | +| **Human-in-Loop** | Four-value tool confirmation (allow / always / deny / exit); inline AskUser form without breaking chat context | +| **Plan mode** | Auto-intercepts write operations; three-button decision (execute / refine / reject); cross-component state sharing | +| **Tool rendering** | 10 dedicated renderers (Bash / Read / Write / Edit / Grep / Glob, etc.); Edit ships with built-in git-diff view | +| **One-click deploy** | Web static hosting → CDN; async WeChat Mini Program deploy; unified artifact aggregated in Deployments tab | +| **Image generation** | AI-generated images auto-uploaded to CloudBase hosting; CDN URL returned; rendered inline as Markdown | +| **Git archive** | Auto-push to remote on task end; branch by envId + directory by conversationId; in-memory credentials, no token leak | +| **Resource dashboard** | Embedded DB / Storage / SQL / Functions management inside the task detail page | +| **Admin console** | User management, env pool monitoring, provision mode config, audit logs | +| **Scheduled tasks** | Cron scheduling + distributed lock to prevent re-entry | +| **Credential security** | AES-256-CBC encrypted storage; STS scoped temporary credentials; logs restricted to static strings only | --- @@ -117,72 +117,66 @@ An open-source alternative to [Lovable](https://lovable.dev) / [v0](https://v0.d ## Quick Start -**Prerequisites** +Env files are **split on purpose** — do not use `.env.cloud` for `pnpm dev` or bake `.env.local` into the cloud image. -- Node.js >= 18 -- Docker -- A Tencent Cloud account (CloudBase environment + API credentials) -- A CodeBuddy API Key or OAuth config +| | Local development | Deploy to CloudRun | +| --- | --- | --- | +| **Init** | `./init.sh` → **1** → `.env.local` | `./init.sh` → **2** → `.env.cloud` | +| **Command** | `pnpm dev` | `pnpm deploy:cloud` | +| **URL** | Web `http://localhost:5174`, API `:3001` | Default domain `https://*.sh.run.tcloudbase.com` | +| **Port** | `PORT=3001` | Container listens on **80** (not 9000; 9000 is sandbox TRW only) | +| **Docs** | [docs/setup.md](docs/setup.md) | [docs/cloudrun-deploy.md](docs/cloudrun-deploy.md) | -**One-shot init** +Need both paths: run `./init.sh` twice (option 1, then option 2). Template: [.env.example](.env.example). ```bash git clone https://github.com/TencentCloudBase/OpenVibeCoding.git cd OpenVibeCoding - -# macOS / Linux / Git Bash / WSL ./init.sh -# Windows (make sure Node.js >= 18 and pnpm are installed first) -node scripts/init.mjs -``` +# macOS / Linux / Git Bash / WSL — or: node scripts/init.mjs (Windows) -The init script runs: Node.js check → pnpm install → `.env.local` generation → Docker check → CloudBase setup → dependency install → CodeBuddy auth → TCR setup → database init. - -For detailed steps and troubleshooting, see [docs/setup.md](docs/setup.md). +pnpm dev # local +pnpm deploy:cloud # cloud (requires @cloudbase/cli) +``` --- ## Development +**Uses `.env.local` only.** Coding sandboxes run in CloudBase Stateful + 沙箱业务镜像, not in local Docker for normal tasks. + ```bash -pnpm dev # Start web (localhost:5174) and server (localhost:3001) together +pnpm dev # Web :5174 + API :3001 pnpm dev:web # Frontend only pnpm dev:server # Backend only -``` - -## Production - -```bash -pnpm build # Build all packages -pnpm start # Start prod server (port 3001, serves API and static files) +pnpm build && pnpm start # Local prod-shaped run (not CloudRun) ``` ## Deploy to CloudRun -This project supports one-click deployment to CloudBase CloudRun (container service). No local Docker required — the script uploads source code and Dockerfile to the cloud for building. +**Separate from local dev.** Reads **`.env.cloud`**, uploads source via CloudBase CLI, builds `Dockerfile` in the cloud. **Prerequisites** -- Completed `./init.sh` initialization (`TCB_ENV_ID`, `TCB_SECRET_ID`, `TCB_SECRET_KEY` configured) -- CloudBase CLI installed: `npm i -g @cloudbase/cli` +- `./init.sh` option **2** → `.env.cloud` with `TCB_SECRET_*`, `TCB_ENV_ID` +- `npm i -g @cloudbase/cli` and `cloudbase login` +- `ASK_USER_BASE_URL` may be a placeholder first; deploy writes back `https://…sh.run.tcloudbase.com` when available -**One-click deploy** +**Run in a real terminal** (upload can take minutes; IDE agents may time out). ```bash pnpm deploy:cloud ``` -The script will: -1. Upload source + Dockerfile to CloudBase for cloud-side image building -2. Deploy as a CloudRun container service (service name: `vibecoding-platform`, port: 80) -3. Query and display the service access URL +The script: prints the console link immediately → uploads source (15s heartbeat) → polls until the release settles → may write back `ASK_USER_BASE_URL` → tries to sync env to the service (`--skip-env-sync` to skip). -**After deployment** +| Flag | Meaning | +| --- | --- | +| `--no-wait` | Submit only, no status polling | +| `--skip-env-sync` | Do not push `.env.cloud` to CloudRun env | -- Access URL format: `https://{serviceName}-{id}.{region}.run.tcloudbase.com` -- Build progress can be viewed in [CloudBase Console](https://tcb.cloud.tencent.com) → CloudRun → Service Details → Deploy Records -- Environment variables should be configured in the console's service settings +Service **`vibecoding-platform`**, public port **80**. Details: [docs/cloudrun-deploy.md](docs/cloudrun-deploy.md). ## Common commands @@ -213,7 +207,9 @@ pnpm opencode:setup # Configure OpenCode provider and models ├── docs/ │ ├── setup.md # Setup walkthrough & troubleshooting │ ├── architecture.md # System architecture -│ └── scf-session-sharing.md # SCF session sharing design +│ ├── cloudrun-deploy.md # CloudRun deploy & env +│ ├── upstream-fork.md # Fork baseline & sync +│ └── scf-session-sharing.md # (legacy) SCF session sharing ├── packages/ │ ├── web/ # React 19 + Vite frontend │ ├── server/ # Hono backend: Auth, Agent orchestration, Sandbox @@ -221,6 +217,7 @@ pnpm opencode:setup # Configure OpenCode provider and models │ └── shared/ # ACP protocol types, task / message schemas ├── scripts/ │ ├── init.mjs # Interactive init script +│ ├── deploy.mjs # CloudRun one-click deploy │ └── setup-tcr.mjs # TCR image registry setup └── init.sh # Quick entry ``` @@ -235,7 +232,7 @@ pnpm opencode:setup # Configure OpenCode provider and models | Backend | Hono, Node.js, Drizzle ORM | | Database | CloudBase DB (primary), SQLite (local fallback) | | AI | `@tencent-ai/agent-sdk` (CodeBuddy), OpenCode ACP | -| Sandbox | CloudBase SCF, TCR container images | +| Sandbox | CloudBase AGS Stateful + 沙箱业务镜像, TCR images | | Auth | JWE session, bcrypt, Arctic (OAuth) | | Storage | CloudBase DB, local .jsonl, Git archive | | Protocol | ACP (JSON-RPC 2.0 + SSE), MCP (Model Context Protocol) | @@ -274,7 +271,6 @@ MAX_SANDBOX_DURATION=300 ANTHROPIC_API_KEY= OPENAI_API_KEY= GEMINI_API_KEY= -GIT_PERSONAL_AUTH= ``` --- @@ -302,7 +298,7 @@ The command will: 1. Call the Tencent CloudBase AI+ endpoint [DescribeAIModels](https://cloud.tencent.com/document/product/876/131318) to fetch models 2. Walk you through configuring the Tencent CloudBase API Key 3. Take the complete config from the catalog and write it to `.opencode/opencode.json` (including npm / baseURL / models) -4. Append the API Key to `packages/server/.env` +4. Append the API Key to `.env.local` ### Example output @@ -328,7 +324,7 @@ The command will: ``` ```bash -# packages/server/.env gets the API Key appended +# .env.local gets the API Key appended CLOUDBASE_API_KEY=eyJhbGciOiJS.xxxxxxxx ``` @@ -368,7 +364,7 @@ pnpm codebuddy:setup The command will: 1. Call the Tencent CloudBase AI+ endpoint [DescribeAIModels](https://cloud.tencent.com/document/product/876/131318) to fetch models enabled in the current environment -2. Check for `CLOUDBASE_API_KEY`; if missing, prompt for input and write it to `packages/server/.env` +2. Check for `CLOUDBASE_API_KEY`; if missing, prompt for input and write it to `.env.local` 3. Also set `CODEBUDDY_USE_CUSTOM_MODELS=true` 4. Generate `packages/server/.config/.codebuddy/models.json` for the SDK to read @@ -393,7 +389,7 @@ The command will: ``` ```bash -# packages/server/.env gets auto-appended +# .env.local gets auto-appended CLOUDBASE_API_KEY=eyJhbGciOiJS.xxxxxxxx CODEBUDDY_USE_CUSTOM_MODELS=true ``` @@ -429,7 +425,7 @@ Append a custom entry to the `models` array (note: do **not** set `vendor` to `c } ``` -Make sure the matching env variable is defined in `packages/server/.env`, and set: +Make sure the matching env variable is defined in `.env.local`, and set: ```bash CODEBUDDY_USE_CUSTOM_MODELS=true diff --git a/docs/architecture.md b/docs/architecture.md index 6c24251..4754d41 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ ## Overview -CloudBase VibeCoding Platform 是一个基于腾讯云 CloudBase 的 AI 编程助手平台。用户通过 Web 界面向 Agent 下达编程指令,Agent 在隔离的 SCF Sandbox 容器中执行代码操作,结果通过 SSE 流式返回并持久化到 CloudBase 数据库。 +CloudBase VibeCoding Platform 是一个基于腾讯云 CloudBase 的 AI 编程助手平台。用户通过 Web 界面向 Agent 下达编程指令,Agent 在 **沙箱 infra**(Stateful 控制面 + 沙箱业务镜像 数据面 + gateway 路由)中执行代码操作,结果通过 SSE 流式返回并持久化到 CloudBase 数据库。 ```mermaid graph TB @@ -27,7 +27,7 @@ graph TB subgraph Infra["CloudBase Infrastructure"] DB[("CloudBase DB")] - SCF["SCF Sandbox"] + SbxInfra["沙箱 infra"] Storage["Cloud Storage"] TCR["TCR Registry"] EnvPool["Environment Pool"] @@ -43,11 +43,11 @@ graph TB Auth --> TaskSvc RuntimeMgr --> CodeBuddy & OpenCode & MiMo CodeBuddy & OpenCode & MiMo --> MCPMiddleware - MCPMiddleware --> SCF - TaskSvc --> SCF + MCPMiddleware --> SbxInfra + TaskSvc --> SbxInfra RuntimeMgr --> Persist --> DB - SCF --> CNB - SCF --> TCR + SbxInfra --> CNB + SbxInfra --> TCR TaskSvc --> Storage Auth --> EnvPool --> DB ``` @@ -251,65 +251,65 @@ Agent 调用子 Agent 时,`parent_tool_use_id` 从 SDK 顶层透传至前端 ` ## Sandbox Module -Sandbox 模块为每个任务 / 会话提供隔离的执行环境。 +Sandbox 模块为每个 **envId** 提供 Stateful 执行环境(沙箱 infra 控制面 + 沙箱业务镜像 数据面),任务共享同一实例上的 `/home/user` 工作区。 ### Architecture ```mermaid flowchart LR - Agent["Agent Runtime"] --> Manager["ScfSandboxManager"] - Manager --> SCF["SCF Container"] - SCF --> FS["File System"] - SCF --> Bash["Bash / PTY"] - SCF --> MCPServer["MCP Server"] - SCF --> PreviewBridge["Preview Bridge"] - SCF --> Git["Git Archive"] - MCPServer --> CloudBase["CloudBase Tools"] - MCPServer --> Deploy["Deployment Tools"] + Agent["Agent Runtime"] --> Provider["StatefulSandboxProvider"] + Provider --> SbxInfra["启动沙箱实例"] + SbxInfra --> 沙箱业务镜像["沙箱业务镜像 :9000"] + 沙箱业务镜像 --> FS["File System /home/user"] + 沙箱业务镜像 --> Bash["Bash / PTY"] + 沙箱业务镜像 --> Preview["/preview/5173 / 7681"] + 沙箱业务镜像 --> Git["Git Archive"] + Provider --> McpClient["stateful-mcp-client"] + McpClient --> CloudBase["CloudBase Tools"] + McpClient --> Deploy["Deployment Tools"] + Web["Web UI"] --> OvcProxy["OpenVibeCoding preview proxy"] + OvcProxy --> Gateway["tcloudbasegateway.com"] + Gateway --> 沙箱业务镜像 ``` -### SCF Sandbox Lifecycle +### Stateful Sandbox Lifecycle -1. **Create or Reuse** — `scfSandboxManager` 根据 conversationId 创建或复用云函数容器 -2. **Health Check** — 轮询 `/health` 等待容器就绪(进度细分:镜像拉取 → 容器就绪 → 工作区初始化) -3. **Init Workspace** — 通过 `ensureSessionRoot(sessionId)` 注入 CloudBase 凭证和环境变量 -4. **Execute** — Agent 通过 HTTP 调用容器内的工具接口 -5. **Archive** — 任务结束(包括 error / cancel)时通过 Git 归档工作区 +1. **Ensure Tool** — `ensureStatefulTool(envId)` 为环境创建或复用沙箱 Tool 模板(`sdt-xxx`),镜像来自 `STATEFUL_SANDBOX_IMAGE` → `TCR_IMAGE` → 公开 TCR 代码默认 +2. **Acquire Instance** — `WORKSPACE_ISOLATION`:`shared` 每 env 单实例;`isolated` 每 task。`StartSandboxInstance` 使用 Tool 模板(不传 boot `CustomConfiguration.Env`);`stateful-tool-warmup` 处理镜像拉取重试 +3. **Data Plane** — `TCB_API_KEY` + `E2b-Sandbox-Id` / `E2b-Sandbox-Port: 9000` 经 gateway 访问 沙箱业务镜像 +4. **Init Workspace** — 实例健康后 `PUT /api/workspace/env`(凭证 + 可选 `GIT_ARCHIVE_*`),`POST /api/workspace/init` 初始化 `/home/user` +5. **Execute** — Agent 工具经 沙箱业务镜像 `/api/tools/*`;CloudBase MCP 由 server 侧 `stateful-mcp-client` 转发 +6. **Archive** — 任务结束时 Git 归档工作区 -### Workspace Provisioning API - -Sandbox 内部使用语义化 Provisioning API 管理工作区层级: +### 沙箱业务镜像 Workspace API | API | Description | | --- | --- | -| `ensureSessionRoot(sessionId)` | 确保 session 根目录就绪,不触发 vite 启动 | -| `ensureWorkspaceFor(sessionId, scopeId?, template)` | 确保工作区就绪,可选挂载 Scope | +| `PUT /api/workspace/env` | 注入 CloudBase 凭证与环境变量 | +| `POST /api/workspace/init` | 初始化工作区,返回 workspace 路径 | +| `GET /health` | 实例健康检查 | +| `POST /api/workspace/snapshot` | 显式 COS 快照(可选) | ### Sandbox Capabilities -每个 Sandbox 容器对外暴露以下能力: +经 gateway 路由到 沙箱业务镜像(OpenVibeCoding 对浏览器暴露 `/api/tasks/:id/preview/:port/*` 反向代理): -| Capability | Endpoint | Description | +| Capability | 沙箱业务镜像 path | Description | | --- | --- | --- | -| File System | `/e2b-compatible/files` | 文件读写(兼容 e2b 协议) | -| Bash | `/api/tools/bash` | Shell 命令执行(PTY) | -| Git Push | `/api/tools/git_push` | 将工作区变更推送到远端 | -| MCP Server | In-memory / HTTP transport | CloudBase 工具和部署工具 | -| Health | `/health` | 容器健康检查 | -| Scope Info | `/api/scope/info` | 子工作区路径和 vite 状态 | - -### Scope API(子工作区隔离) - -同一 session 内支持多个相互隔离的子工作区: +| File System | `/api/tools/read`, `write`, … | 文件读写 | +| Bash | `/api/tools/bash` | Shell 命令执行 | +| Web Terminal | `/preview/7681/` (ttyd) | 浏览器终端 | +| Vite preview | `/preview/5173/` | 开发服务器(默认端口 5173) | +| Git Push | `POST /api/extend/git_push` | 工作区推送到远端(OpenVibeCoding 经 workspace/env 注入 `GIT_ARCHIVE_*` / `ENABLE_GIT_ARCHIVE`) | +| Miniprogram deploy | `POST /api/jobs/miniprogram-deploy` | 启动部署 job(沙箱业务镜像 `ENABLE_VIBECODING`) | +| Job poll | `GET /api/jobs/:jobId` | 轮询 job 状态 / 日志 | +| Health | `/health` | 实例健康检查 | -- 通过请求头 `X-Scope-Id` / `X-Scope-Template` 控制 -- 每个 scope 独立运行 vite dev server,端口 5173-5199 动态分配 -- `GET /api/scope/info` 返回工作区路径和 vite 状态(`viteState: "starting" | "ready" | "failed"`) -- `spawnVite` 将 tar 解压 + npm install 在 `setImmediate` 中异步执行,HTTP handler 立即返回,不阻塞 +> **已移除**:旧 SCF 时代的子工作区 Scope API(`X-Scope-Id`、`/api/scope/info`、5173–5199 多端口)。`user_resources.scope` 仍指 **CloudBase 环境隔离**(shared / isolated / task),与 沙箱业务镜像 Scope 无关。 ### MCP Tool Proxy -Sandbox 内通过 MCP (Model Context Protocol) 向 Agent 提供工具能力。 +Server 侧 `stateful-mcp-client` 通过 MCP 向 Agent 提供 CloudBase / 部署工具(不再依赖容器内 `sandbox-mcp-proxy`)。 **CloudBase MCP(内置)** — 全局 CloudBase MCP HTTP server,复用 Express 端口,零额外 TCP 开销;支持 stdio 和 HTTP 两种模式。工具通过 `lib/mcp-middleware/` 的 koa 风格中间件框架进行拦截 / 新增 / 过滤,policy 文件按工具名平铺在 `middleware/mcp/cloudbase/`: @@ -374,7 +374,7 @@ interface Artifact { | 微信小程序 | MCP 工具 `publishMiniprogram` | `'image'`(预览二维码)/ `'json'`(上传结果) | | 图片生成 | Default 模式 ImageGen tool | `'link'`(CDN URL) | -小程序部署超过 60s 时接口异步返回 `{ async: true, jobId }`,客户端轮询 `GET /api/miniprogram/deploy/:jobId` 获取实时构建日志。 +小程序部署超过 60s 时 沙箱业务镜像 返回 HTTP 202 + `jobId`;Agent 用 `getDeployJobStatus` 轮询 `GET /api/jobs/:jobId`(OpenVibeCoding 服务端适配层转成旧 envelope)。 前端 Deployments 标签页统一渲染所有 deployment 记录,根据字段自动选择卡片样式(链接卡片 / 二维码卡片 / 通用卡片)。 @@ -413,7 +413,7 @@ sequenceDiagram participant Web participant Server participant Runtime as ACP Runtime - participant Sandbox as SCF Sandbox + participant Sandbox as 沙箱 infra + 沙箱业务镜像 participant DB as CloudBase DB participant Git as Git Archive @@ -423,7 +423,7 @@ sequenceDiagram Server->>Runtime: chatStream(prompt, options) Runtime->>Sandbox: Create or reuse sandbox Sandbox-->>Runtime: Health check OK - Runtime->>Sandbox: ensureSessionRoot (inject credentials) + Runtime->>Sandbox: workspace/env + workspace/init Runtime->>Runtime: Call LLM via selected runtime loop Agent execution @@ -455,7 +455,7 @@ sequenceDiagram | Backend | Hono, Node.js, Drizzle ORM | | Database | CloudBase DB (primary), SQLite (local fallback) | | AI | `@tencent-ai/agent-sdk` (CodeBuddy), OpenCode ACP, MiMo | -| Sandbox | CloudBase SCF, TCR container images | +| Sandbox | 沙箱 infra(Stateful + 沙箱业务镜像), TCR images | | Auth | JWE session, bcrypt, Arctic (OAuth) | | Persistence | CloudBase DB, local .jsonl, Git archive | | Protocol | ACP (JSON-RPC 2.0 + SSE), MCP (Model Context Protocol) | @@ -686,6 +686,6 @@ erDiagram ## Related Documents - [Setup Guide](./setup.md) — 初始化流程、环境变量、验证与排障 -- [SCF Session Sharing](./scf-session-sharing.md) — 沙箱会话共享方案 +- [SCF Session Sharing](./scf-session-sharing.md) — **已废弃**,仅作 SCF 时代历史参考 - [Cron Task Plan](./crontask-cloudfunction-plan.md) — 定时任务云函数演进规划 - [ACP Runtime Abstraction](./acp-runtime-abstraction.md) — ACP Runtime 抽象层设计 diff --git a/docs/cloudrun-deploy.md b/docs/cloudrun-deploy.md new file mode 100644 index 0000000..b669851 --- /dev/null +++ b/docs/cloudrun-deploy.md @@ -0,0 +1,61 @@ +# CloudRun 部署(云托管) + +一体容器(`Dockerfile`)在进程内监听 **`PORT`(云端为 80)**。密钥**不打进镜像**(`.dockerignore` 排除 `.env*`)。 + +## 和本地开发的关系 + +| | 本地 | 云托管 | +| --- | --- | --- | +| 环境文件 | `.env.local` | `.env.cloud` | +| 启动 | `pnpm dev` | `pnpm deploy:cloud` | +| 对外端口 | 3001 + Vite 5174 | **80** | +| 沙箱 TRW | 经 gateway,容器内 **9000** | 同上(与云托管 80 **无关**) | + +不要在云托管控制台把服务端口改成 9000。 + +## 环境文件 + +| 文件 | 用途 | +| --- | --- | +| `.env.example` | 文档模板(可提交) | +| `.env.local` | 仅本地 `pnpm dev` | +| `.env.cloud` | `pnpm deploy:cloud`:CLI 凭证 + 运行时变量(尝试 API 同步) | + +`./init.sh` 每次只生成其一;两份都要则 init 跑两次。云端常见差异:`PORT=80`、`NODE_ENV=production`、`ASK_USER_BASE_URL` 为公网根 URL。 + +## 一键部署 + +```bash +pnpm deploy:cloud +``` + +执行顺序(`scripts/deploy.mjs`): + +1. **立即**输出控制台链接(`envId` + 服务 `vibecoding-platform` → 部署记录 Tab) +2. `cloudbase cloudrun deploy` 上传仓库 + `Dockerfile`(上传阶段 CLI **无百分比**,脚本约每 15s 打印心跳) +3. 提交成功后**轮询** API:服务 `Status`、部署记录(若有);Docker 构建明细以控制台为准 +4. **`ASK_USER_BASE_URL` 写回**:若仍为占位 / `YOUR-SERVICE` / 仅 localhost,且能读到默认域名,则写入 `.env.cloud` + - 示例:`https://vibecoding-platform-198076-5-1253192607.sh.run.tcloudbase.com` + - 无 scheme 的 hostname 会自动补上 `https://` +5. 尝试 `UpdateCloudRunServer` 同步 `.env.cloud` → 云托管环境变量;失败时在控制台 → 服务配置 **手贴** + +### 可选参数 + +```bash +pnpm deploy:cloud --no-wait # 只提交,不轮询(CI / 自己盯控制台) +pnpm deploy:cloud --skip-env-sync # 不同步环境变量 +``` + +### 建议 + +- 在**本机终端**跑完整部署,避免 IDE 内置终端 2–3 分钟超时打断上传 +- 构建失败:控制台 → 部署记录 → 查看 Docker 构建日志(与本地 `pnpm build` 同源) + +## 部署后检查 + +- 默认域名可访问:`GET /health` +- `.env.cloud` 中 `ASK_USER_BASE_URL` 与浏览器打开的公网根 URL 一致(勿为 `127.0.0.1`) +- 登录、创建任务,确认沙箱与预览 +- **勿**配置 `STATEFUL_TOOL_ID`(多副本用 DB + `openvibecoding-{TCB_ENV_ID}`) + +沙箱问题见 [setup.md](./setup.md) 排障章节。 diff --git a/docs/crontask-cloudfunction-plan.md b/docs/crontask-cloudfunction-plan.md index 0859fec..bad70ff 100644 --- a/docs/crontask-cloudfunction-plan.md +++ b/docs/crontask-cloudfunction-plan.md @@ -94,7 +94,7 @@ exports.main = async (event, context) => { | 更新环境变量 | `updateFunctionConfig` | | 删除云函数 | 通过 SDK 删除 | -这些 API 在沙箱 MCP 工具中已有封装(`sandbox-mcp-proxy.ts`),可复用。 +这些 API 在 `stateful-mcp-client` / CloudBase MCP 中间件中封装,可复用。 ### 服务端新增接口 diff --git a/docs/scf-session-sharing.md b/docs/scf-session-sharing.md index 25d1ac6..56c97ba 100644 --- a/docs/scf-session-sharing.md +++ b/docs/scf-session-sharing.md @@ -1,5 +1,7 @@ # SCF 沙箱 Session 共享改造方案 +> **已废弃**:`feature/stateful-infra` 已迁移至 **沙箱 infra(Stateful + 沙箱业务镜像)**(`StatefulSandboxProvider`、`ensureStatefulTool`)。下文描述的是旧 SCF 架构,仅供历史对照;新部署见 [setup.md](./setup.md) 与 [architecture.md](./architecture.md) 的 Sandbox 章节。 + ## 背景 原有架构中,每个 `conversationId` 对应一个独立的 SCF 容器实例(通过 `X-Cloudbase-Session-Id` 标识)。这导致资源消耗大,同一环境下多个会话无法共享状态。 diff --git a/docs/setup.md b/docs/setup.md index fc95334..66047d3 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -10,8 +10,9 @@ ## 前置条件 ### 必需项 -- Node.js >= 18 -- Docker 已安装并已启动 +- **Node.js 22.x**(`>=22 <23`)。`packages/server` 使用 `better-sqlite3` 与 `tsup --target node22`;更高主版本(如 26)会导致原生模块 ABI 不匹配或安装失败。根目录 `.nvmrc` 为 `22`;可用 `mise use node@22` / `nvm use`。 +- pnpm 10+ +- Docker **仅在你选择 init 里的 TCR 推镜像时需要**(日常 Stateful 开发不需要) - 腾讯云账号,且已准备 CloudBase 环境 - 可用的腾讯云 API 密钥(`SecretId` / `SecretKey`) - 至少一种 CodeBuddy 认证方式: @@ -53,15 +54,18 @@ node scripts/init.mjs flowchart TD A[执行 ./init.sh] --> B[检查 Node.js] B --> C[检查或安装 pnpm] - C --> D[生成 .env.local] - D --> E[检查 Docker] - E --> F[配置 CloudBase] - F --> G[生成 packages/server/.env] - G --> H[安装依赖] - H --> I[配置 CodeBuddy 认证] - I --> J[配置 TCR] - J --> K[初始化数据库] - K --> L[完成初始化] + C --> D[选择 1 local 或 2 cloud] + D --> F[配置 CloudBase] + F --> G[CodeBuddy 认证] + G --> H[Stateful TCB_API_KEY 等] + H --> I[setupApplicationEnv: 写 .env.local 或 .env.cloud] + I --> J[安装依赖] + J --> K{TCR 可选} + K -->|是| L[Docker + setup-tcr] + K -->|否| M[跳过] + L --> N[初始化数据库] + M --> N + N --> O[完成初始化] ``` ## 各步骤说明 @@ -69,35 +73,52 @@ flowchart TD | 步骤 | 脚本位置 | 作用 | | --- | --- | --- | | 1 | `init.sh` | 检查 Node.js 与 pnpm 是否可用 | -| 2 | `scripts/init.mjs#setupEnv` | 创建根目录 `.env.local`,写入基础密钥和默认值 | -| 3 | `scripts/init.mjs#checkDocker` | 确认 Docker 可用,因为后续 TCR 配置依赖本地镜像能力 | -| 4 | `scripts/init.mjs#setupCloudbaseConfig` | 引导输入腾讯云密钥、选择 `TCB_ENV_ID`、设置 `TCB_PROVISION_MODE` | -| 5 | `scripts/init.mjs#setupServerEnv` | 生成 `packages/server/.env`,写入服务端运行需要的配置 | -| 6 | `scripts/init.mjs#installDependencies` | 执行 `pnpm install`,并尝试重建 `better-sqlite3` | -| 7 | `scripts/init.mjs#setupCodebuddy` | 配置 CodeBuddy API Key 或 OAuth | -| 8 | `scripts/init.mjs#setupTcr` | 配置 TCR 镜像仓库并推送默认镜像 | -| 9 | `scripts/init.mjs` 主流程 | 根据数据库模式完成数据库初始化 | +| 2 | `scripts/init.mjs#promptEnvGenerationTarget` | **二选一**:1) `.env.local` 或 2) `.env.cloud`(无第三项) | +| 3 | `scripts/init.mjs#setupCloudbaseConfig` | 引导输入腾讯云密钥、选择 `TCB_ENV_ID`、设置 `TCB_PROVISION_MODE` | +| 4 | `scripts/init.mjs#setupCodebuddy` | 配置 CodeBuddy API Key 或 OAuth | +| 5 | `scripts/init.mjs#setupStatefulSandbox` | 可选填写 `TCB_API_KEY`、`STATEFUL_SANDBOX_IMAGE` | +| 6 | `scripts/init.mjs#setupApplicationEnv` | 写入步骤 2 选中的那一份 | +| 7 | `scripts/init.mjs#installDependencies` | 执行 `pnpm install`,并尝试重建 `better-sqlite3` | +| 8 | `scripts/init.mjs#setupTcr` | **可选**:仅维护沙箱业务镜像时需要 Docker + TCR | +| 9 | `scripts/init.mjs` 主流程 | 数据库、Git 归档、Skills、自定义模型等 | ## 配置文件职责 -### `.env.local` -根目录 `.env.local` 主要保存: -- `JWE_SECRET` -- `ENCRYPTION_KEY` -- `NEXT_PUBLIC_AUTH_PROVIDERS` -- 一些默认限制项(如消息数、sandbox 持续时间) +| 文件 | 提交 git | 用途 | +| --- | --- | --- | +| `.env.example` | 是 | 字段说明模板 | +| `.env.local` | 否 | 本地开发:`pnpm dev:server` 通过 `--env-file=../../.env.local` 加载 | +| `.env.cloud` | 否 | 云托管运行时:`pnpm deploy:cloud` 同步到服务 EnvParams | + +前后端一体:仅 **Node 服务端**读取 env;Vite dev 将 `/api` 代理到 `:3001`,无需第二份密钥文件。 + +两把加密密钥(均在 `.env.local` / `.env.cloud` 中各一份,值应一致): +- `JWE_SECRET` — 登录 session +- `ENCRYPTION_KEY` — MCP 连接器密文 + +### init 生成策略 + +- 每次 `./init.sh` **只生成一个文件**:选 1 → `.env.local`,选 2 → `.env.cloud`。 +- 两个都要:先跑一遍选 1,再跑一遍选 2(CloudBase / CodeBuddy 等步骤会再走一遍)。 +- **覆盖**:仅针对本次选中的文件询问;选「否」则跳过写入。 +- 选 **2** 时会问 `ASK_USER_BASE_URL`(云托管公网根 URL);首次部署前可回车用占位,`pnpm deploy:cloud` 在能读到默认域名时会写回 `.env.cloud`。 + +## 本地开发流程 + +1. `./init.sh`(或已有 `.env.local`) +2. `pnpm dev` → Web `:5174`,API `:3001`(server 读 `.env.local`) +3. 改 env 后需**重启** server(`--env-file` 只在启动时加载) + +详见 README「本地开发」一节。 -它更偏向项目级和前后端共享的基础配置。 +## 部署到云托管 -### `packages/server/.env` -`packages/server/.env` 主要保存服务端运行所需配置,例如: -- CloudBase 相关配置(`TCB_ENV_ID`、`TCB_SECRET_ID`、`TCB_SECRET_KEY`) -- CodeBuddy 认证配置 -- 数据库提供方配置 -- SCF Sandbox / TCR 配置 -- 可选的 GitHub OAuth、代理配置 +与本地开发 **完全分开**(不共用 `.env.local`)。详见 [cloudrun-deploy.md](./cloudrun-deploy.md)。 -初始化脚本会优先把 CloudBase 和服务端相关配置写入这里。 +1. `./init.sh` 选 **2** → `.env.cloud`(含 `TCB_SECRET_*`、`TCB_ENV_ID`) +2. `npm i -g @cloudbase/cli` 且 `cloudbase login` +3. 在本机终端执行 `pnpm deploy:cloud`(会先打控制台链接,再上传、轮询;`ASK_USER_BASE_URL` 可从 `*.sh.run.tcloudbase.com` 自动写回) +4. 若 env API 同步失败,到控制台粘贴 `.env.cloud` 中的运行时变量 ## 关键环境变量 @@ -121,14 +142,39 @@ flowchart TD | `CODEBUDDY_CLIENT_SECRET` | 二选一 | OAuth 模式下使用 | | `CODEBUDDY_OAUTH_ENDPOINT` | 否 | OAuth Token 端点,默认使用国内地址 | -### Sandbox / TCR +### Stateful Sandbox / TCR | 变量 | 必需 | 说明 | | --- | --- | --- | -| `SANDBOX_IMAGE_TYPE` | 否 | 镜像类型,默认 `personal` | -| `SANDBOX_IMAGE_URI` | 是 | SCF Sandbox 所使用的镜像 URI | -| `SANDBOX_IMAGE_PORT` | 否 | 容器暴露端口,默认 `9000` | -| `TCR_IMAGE` | 建议 | `setup-tcr` 成功后会写入,用于 sandbox 镜像配置 | +| `TCB_API_KEY` | 是 | gateway 数据面 Bearer(与 `TCB_ENV_ID` 配套) | +| `ENABLE_AUTH_MODE` | 否 | 默认 `false`:`StartSandboxInstance` 使用 `AuthMode: NONE`,数据面仅 gateway 头 | +| `TCB_ACCESS_TOKEN` | 条件 | `ENABLE_AUTH_MODE=true` 时**必填**(`sit_*`);server 请求加 `X-Access-Token`,起实例不传 `NONE` | +| `STATEFUL_SANDBOX_IMAGE` | 否 | 首次 `CreateSandboxTool` 或镜像漂移 reconcile;不配则用公开 TCR 默认或 `TCR_IMAGE` | +| `TCR_IMAGE` | 否 | `pnpm setup:tcr` 写入你的命名空间 | +| `SANDBOX_TTL_SECONDS` | 否 | AGS 实例超时(**秒**),默认 `1800`(30m);写入 `StartSandboxInstance.Timeout` 与 `CreateSandboxTool.DefaultTimeout` | +| `WORKSPACE_ISOLATION` | 否 | `shared` / `isolated` — 沙箱**实例**是否跨任务复用(与 main 同名) | +| `MAX_SANDBOX_DURATION` | 否 | 任务字段默认上限(**秒**,默认 300);**不**控制 AGS Stop,与 `SANDBOX_TTL_SECONDS` 无关 | +| `GIT_ARCHIVE_*` | 否 | 工作区归档;实例就绪后 `PUT /api/workspace/env` 注入(非 Start 时传 boot env) | +| `STATEFUL_PUBLIC_TCR_REPOSITORY` | 否 | 公开 TCR 仓库名,默认 `tcb-sandbox-public-cbe88d` | + +**网关**:固定 `https://{TCB_ENV_ID}.api.tcloudbasegateway.com/v1/sandbox/-`,无 env 覆盖。 + +**数据面鉴权(两层)**:`X-Cloudbase-Authorization`(`TCB_API_KEY`)始终需要;开启 `ENABLE_AUTH_MODE` 后还需 `X-Access-Token`(`TCB_ACCESS_TOKEN`,来自 AGS `AcquireSandboxInstanceToken`)。`ENABLE_AUTH_MODE` 会通过 `PUT /api/workspace/env` 注入沙箱业务镜像(不含 token)。 + +**小程序**:沙箱业务镜像 `/api/jobs/miniprogram-deploy` **默认开启**,无需 env 开关。 + +**镜像**:须为 `ccr.ccs.tencentyun.com/...`(沙箱 infra 使用 TCR 个人版,`ImageRegistryType: personal`)。团队公开默认见 `packages/server/src/sandbox/stateful-vibecoding-image.ts`;自部署用自有命名空间推送后设 `STATEFUL_SANDBOX_IMAGE` 或跑 `pnpm setup:tcr`。勿在文档或 git 中提交 API Key / TCR 密码。 + +### 沙箱实例模式(`WORKSPACE_ISOLATION`) + +与 **`TCB_PROVISION_MODE`(用户 CloudBase 环境)** 独立,详见 [README-zh.md](../README-zh.md) 环境变量一节。 + +| 值 | 行为 | +| --- | --- | +| `shared`(默认) | 同一支撑 `TCB_ENV_ID` 下,多任务复用沙箱 infra 上同一运行实例(`ensureSingleEnvInstance`) | +| `isolated` | 每任务独立实例;优先复用任务上的 `sandboxId`,否则新建 | + +配置位置:`.env.local` 的 `WORKSPACE_ISOLATION`;Admin「系统设置」里的 `sandbox_instance_mode`(DB)优先级更高。改模式后**新建任务**最可靠;旧任务若 DB 里已写死 `sandboxMode` 可能仍为旧值。 ## 用户环境模式 @@ -158,8 +204,9 @@ flowchart TD ### 文件与配置 - [ ] 根目录存在 `.env.local` -- [ ] `packages/server/.env` 已生成 -- [ ] `packages/server/.env` 中已包含 `TCB_ENV_ID` +- [ ] `.env.local` 已生成 +- [ ] `.env.local` 中已包含 `TCB_ENV_ID` +- [ ] `.env.cloud` 已生成(部署用) - [ ] 已配置 CodeBuddy API Key 或 OAuth 信息 - [ ] 已生成或写入 sandbox 镜像相关配置 @@ -394,7 +441,7 @@ pnpm opencode:setup 1. 调用 腾讯云开发 AI+ 接口 [DescribeAIModels](https://cloud.tencent.com/document/product/876/131318) 拉取模型 2. 引导并配置腾讯云开发 API Key 3. 从 catalog 取完整配置写入 `.opencode/opencode.json`(含 npm/baseURL/models 等) -4. 把 API Key 写入 `packages/server/.env` +4. 把 API Key 写入 `.env.local` 配置完成后**必须重启 server**(Node.js 的 `--env-file` 只在启动时加载一次)。 @@ -403,7 +450,7 @@ pnpm opencode:setup | 文件 | 作用 | 是否 gitignore | |---|---|---| | `.opencode/opencode.json` | provider + model 定义(opencode 子进程 + server 均读取) | 否(应提交) | -| `packages/server/.env` | API Key 等凭证 | 是 | +| `.env.local` | API Key 等凭证 | 是 | ### 常见问题 @@ -434,7 +481,7 @@ pnpm codebuddy:setup 脚本会自动完成以下操作: 1. 调用 腾讯云开发 AI+ 接口 [DescribeAIModels](https://cloud.tencent.com/document/product/876/131318) 拉取当前环境已开通的模型 -2. 检查 `CLOUDBASE_API_KEY`,缺失时引导输入并自动写入 `packages/server/.env` +2. 检查 `CLOUDBASE_API_KEY`,缺失时引导输入并自动写入 `.env.local` 3. 同时设置 `CODEBUDDY_USE_CUSTOM_MODELS=true` 4. 生成 `packages/server/.config/.codebuddy/models.json` 供 SDK 读取 @@ -445,7 +492,7 @@ pnpm codebuddy:setup | 文件 | 作用 | 是否 gitignore | |---|---|---| | `packages/server/.config/.codebuddy/models.json` | 模型定义列表(`@tencent-ai/agent-sdk` 读取) | 是(自动生成) | -| `packages/server/.env` | API Key 与 `CODEBUDDY_USE_CUSTOM_MODELS` 开关 | 是 | +| `.env.local` | API Key 与 `CODEBUDDY_USE_CUSTOM_MODELS` 开关 | 是 | ### 同步与自定义模型规则 @@ -477,7 +524,7 @@ packages/server/.config/.codebuddy/models.json } ``` -同时确保在 `packages/server/.env` 中提供对应的环境变量,并设置: +同时确保在 `.env.local` 中提供对应的环境变量,并设置: ```bash CODEBUDDY_USE_CUSTOM_MODELS=true @@ -488,7 +535,7 @@ CODEBUDDY_USE_CUSTOM_MODELS=true | 问题 | 原因 | 解决 | |---|---|---| | 前端 CodeBuddy agent 模型列表为空 | `models.json` 未生成或 `CODEBUDDY_USE_CUSTOM_MODELS` 未设置 | 运行 `pnpm codebuddy:setup` | -| 前端有模型但 agent 请求失败 | `CLOUDBASE_API_KEY` 无效或已过期 | 检查 `packages/server/.env` 中的 API Key,或重新创建 | +| 前端有模型但 agent 请求失败 | `CLOUDBASE_API_KEY` 无效或已过期 | 检查 `.env.local` 中的 API Key,或重新创建 | | 已从 CloudBase 删除的模型仍存在 | 旧版本脚本保留了已删除模型 | 重跑 `pnpm codebuddy:setup`,会自动清理 vendor 为 `cloudbase` 的已删除模型 | | 配置后前端没变化 | server 未重启 | 重启 `pnpm dev:server` | @@ -497,7 +544,7 @@ CODEBUDDY_USE_CUSTOM_MODELS=true 如果不使用交互式脚本,建议按照以下顺序手动处理: 1. 准备 `.env.local` -2. 准备 `packages/server/.env` +2. 准备 `.env.local` 3. 安装依赖 4. 配置 CodeBuddy 认证 5. 配置 TCR 镜像 @@ -510,4 +557,4 @@ CODEBUDDY_USE_CUSTOM_MODELS=true - [根目录 README](../README.md) - [系统架构文档](./architecture.md) -- [SCF Session 共享方案](./scf-session-sharing.md) +- [SCF Session 共享方案](./scf-session-sharing.md)(**已废弃**,stateful 分支请以上表为准) diff --git a/docs/upstream-fork.md b/docs/upstream-fork.md new file mode 100644 index 0000000..82a8d29 --- /dev/null +++ b/docs/upstream-fork.md @@ -0,0 +1,118 @@ +# Upstream baseline(硬分叉) + +记录本仓库相对 **TencentCloudBase/OpenVibeCoding** 的分叉与同步历史。本线以 **硬分叉** 为前提:沙箱 infra 等改动与上游 **不保证长期可 merge**,合并后仍需人工回归。 + +## 血缘 + +| 层级 | 仓库 | +| --- | --- | +| 最初模板 | [vercel-labs/coding-agent-template](https://github.com/vercel-labs/coding-agent-template) | +| **直接上游(功能同步来源)** | [TencentCloudBase/OpenVibeCoding](https://github.com/TencentCloudBase/OpenVibeCoding) | +| **本线** | 当前仓库 `feature/stateful-infra` 及后续分支(沙箱 infra / Stateful + 沙箱业务镜像) | + +## 硬分叉基线(不变) + +首次从上游拉出本线时的截止点,**不随后续 merge 改写**: + +| 项 | 值 | +| --- | --- | +| 上游仓库 | `https://github.com/TencentCloudBase/OpenVibeCoding` | +| 上游默认分支 | `main` | +| **硬分叉基线 commit** | `43c3e6038d833481c2fd0d4d206f4a801de7a750` | +| 说明 | `Merge branch 'feautre/env-pool'` | +| 日期 | 2026-05-21 | +| 本线记录分支 | `feature/stateful-infra` | + +在此 commit 之前与上游同源;**之后**本线增加沙箱 infra 等不可直接兼容的改动。 + +### 本线相对上游的持久差异(示例) + +- 沙箱 infra:Stateful Tool / 实例生命周期、gateway 数据面、沙箱业务镜像 vibecoding 镜像 +- `WORKSPACE_ISOLATION`(shared / isolated,与 main 同名)与进度文案 +- 公开 TCR 默认镜像、`stateful-vibecoding-image` 解析链 +- 与 SCF 时代假设脱钩的文档与默认配置 + +## 上游同步记录 + +| 日期 | 方式 | 上游 `main` 顶 | 本线 merge commit | 备注 | +| --- | --- | --- | --- | --- | +| 2026-05-21 | `git merge origin/main` | `a878ddbbee2f6320395dc7f84a7e6a068c524e75` | `20dedbdbb00997d8f23c289317836de14df44d60` | 无冲突;含下方 5 个上游 commit | +| 2026-05-25 | `git merge origin/main` | `4592517`(fix readme 等) | (merge commit) | 约 10 文件冲突;保留 AGS/沙箱业务镜像 路径 | +| 2026-05-27 | `git merge origin/main` → `feature/stateful-infra` | `dc70b08d8e3019884b51a9b4ae219b7a1af8d439` | `1e20ed4`(含 merge `0d4e65b`) | 试跑分支 `merge-trial/main-into-stateful` 已 fast-forward 合入;冲突同上;`type-check` / `lint` / `build` 通过 | + +**历史:2026-05-21 并入**(`43c3e60..a878ddb`): + +| SHA | 说明 | +| --- | --- | +| `a5543ba` | feat: 优化 opencode 安装描述 | +| `a774c74` | feat: codebuddy 支持 tokenhub | +| `03745a9` | feat: 初始化添加配置自定义模型功能 | +| `4669043` | Merge pull request #23(CodeBuddy TokenHub) | +| `a878ddb` | feat: 更新 agent 选项 | + +**本次并入的上游 commit**(`4592517..dc70b08`,2026-05-27 试跑合并): + +| SHA | 说明 | +| --- | --- | +| `6dc789f` | docs: add community qrcode to readmes | +| `8fcb9f8` | docs: add community | +| `90fe835` | docs: update readme community | +| `f5be7cb` | feat: Coding 模式自动放行写工具 | +| `a392f46` | fix(opencode): OpenCode runtime 云托管可用 | +| `1236a37` | feat: podman fallback for docker | +| `e042616` | fix: TCR login + podman | +| `f801dd3` | feat: enterprise TCR in init.mjs | +| `645b1f2` | docs: enterprise TCR setup guide | +| `24f9bba` | Merge PR #27 podman-fallback | +| `dc70b08` | feat(init): TCR enterprise registry | + +**当前对齐状态**(2026-05-27,分支 `feature/stateful-infra`): + +- `git merge-base HEAD origin/main` → `dc70b08`(与上游 `main` 最新对齐) +- 功能分支顶:`1e20ed4`(fast-forward 自试跑 merge `0d4e65b` + 上游同步文档) +- 本线保留:Stateful 沙箱、`TCB_API_KEY`、`.env.local` / `.env.cloud`、preview WebSocket 代理(不转发浏览器 `Origin`) +- 从上游并入:`opencode-ai`、TCR 企业版 + podman、`coding-mode` 写工具自动放行、社区文档 +- 回归:本地 `pnpm dev`;云端 `pnpm deploy:cloud`(服务 `vibecoding-platform`) +- 中文与 stateful 说明:[README-zh.md](../README-zh.md)、[setup.md](./setup.md) + +下次看上游新提交: + +```bash +git fetch origin +git log dc70b08..origin/main --oneline +``` + +## 偶尔从上游同步(推荐流程) + +```bash +git fetch origin + +# 自上次对齐的顶往下看 +git log dc70b08d8e3019884b51a9b4ae219b7a1af8d439..origin/main --oneline + +# 整分支合并(可能冲突,需人工解) +git merge origin/main + +# 或单 commit +git cherry-pick +``` + +大范围对齐后:在 **上游同步记录** 表追加一行,并视情况把「自上次对齐的顶」更新为新 `origin/main` HEAD。仅当 intentionally 重置分叉叙事时才改 **硬分叉基线** 表。 + +## 校验(可选) + +```bash +# 硬分叉点仍在历史中 +git merge-base HEAD 43c3e6038d833481c2fd0d4d206f4a801de7a750 + +# 是否与上游 main 对齐到记录中的顶 +git merge-base HEAD origin/main +``` + +## 远程 + +| remote | 用途 | +| --- | --- | +| `origin` | 指向 `TencentCloudBase/OpenVibeCoding`(本仓库 push 需组织 Write 或 fork) | + +可选单独 `upstream` 同名仓库仅 fetch,与 `origin` 二选一即可。 diff --git a/init.sh b/init.sh index a3da7df..c43c706 100755 --- a/init.sh +++ b/init.sh @@ -3,6 +3,8 @@ # ======================================== # Project Initialization Script # Entry point for setting up the project +# +# Stateful 分支:init 写 .env.local + .env.cloud;不跑 Docker/TCR(见 scripts/init.mjs) # ======================================== set -e diff --git a/package.json b/package.json index 21d9b54..932cdf7 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,10 @@ "name": "coding-agent-template", "version": "2.0.0", "private": true, + "engines": { + "node": ">=22.0.0 <23.0.0", + "pnpm": ">=10.0.0" + }, "scripts": { "dev": "concurrently \"pnpm dev:server\" \"pnpm dev:web\"", "dev:web": "pnpm --filter @coder/web dev", @@ -28,6 +32,7 @@ "@cloudbase/manager-node": "^4.10.6", "@cloudbase/node-sdk": "^3.18.1", "@eslint/eslintrc": "^3.3.3", + "@inquirer/password": "^4.0.23", "@tailwindcss/postcss": "^4.1.18", "@types/ms": "^2.1.0", "@types/node": "^20.19.30", diff --git a/packages/server/package.json b/packages/server/package.json index a3b3dce..cba397f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -2,6 +2,9 @@ "name": "@coder/server", "version": "0.1.0", "private": true, + "engines": { + "node": ">=22.0.0 <23.0.0" + }, "type": "module", "exports": { ".": "./dist/index.js", @@ -9,12 +12,15 @@ "./agent/cloudbase-agent.service": "./src/agent/cloudbase-agent.service.ts" }, "scripts": { - "dev": "tsup src/sandbox/tool-override.ts --format cjs --outDir dist/sandbox --no-splitting --silent && tsup src/util/skill-loader-override.ts --format cjs --outDir dist/util --no-splitting --silent && DOTENVX_PATH=.env tsx watch --env-file=.env src/index.ts", + "dev": "tsup src/sandbox/tool-override.ts --format cjs --outDir dist/sandbox --no-splitting --silent && tsup src/util/skill-loader-override.ts --format cjs --outDir dist/util --no-splitting --silent && tsx watch --env-file=../../.env.local src/index.ts", "build": "tsup src/sandbox/tool-override.ts --format cjs --outDir dist/sandbox --no-splitting && tsup src/util/skill-loader-override.ts --format cjs --outDir dist/util --no-splitting && tsup src/index.ts --format esm --target node22", "start": "node dist/index.js", "test": "vitest run", "test:watch": "vitest", - "check:tool-schemas": "tsx scripts/check-tool-schemas.mts" + "check:tool-schemas": "tsx scripts/check-tool-schemas.mts", + "verify:stateful": "tsx scripts/verify-stateful-e2e.ts", + "stop:stateful-instances": "tsx scripts/stop-stateful-instances.ts", + "update:stateful-tool-image": "tsx scripts/update-stateful-tool-image.ts" }, "dependencies": { "@agentclientprotocol/sdk": "^0.21.0", @@ -33,6 +39,7 @@ "better-sqlite3": "^12.8.0", "cos-nodejs-sdk-v5": "^2.15.4", "drizzle-orm": "^0.36.4", + "e2b": "^2.19.5", "fflate": "^0.8.2", "got": "^15.0.5", "gray-matter": "^4.0.3", @@ -42,6 +49,7 @@ "node-cron": "^4.2.1", "tencentcloud-sdk-nodejs": "^4.1.205", "uuid": "^11.0.0", + "ws": "^8.18.0", "zod": "^4.3.6" }, "devDependencies": { @@ -50,6 +58,7 @@ "@types/node": "^22.0.0", "@types/node-cron": "^3.0.11", "@types/uuid": "^11.0.0", + "@types/ws": "^8.5.13", "tsup": "^8.0.0", "tsx": "^4.21.0", "typescript": "~5.7.0", diff --git a/packages/server/scripts/debug-start-instance.ts b/packages/server/scripts/debug-start-instance.ts new file mode 100644 index 0000000..3c66e76 --- /dev/null +++ b/packages/server/scripts/debug-start-instance.ts @@ -0,0 +1,67 @@ +/** + * Debug StartSandboxInstance — prints full AGS error + tool CustomConfiguration. + */ +import { config } from 'dotenv' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { buildGitArchiveInstanceEnv } from '../src/sandbox/git-archive.js' +import { isStatefulAuthModeEnabled } from '../src/sandbox/stateful-sandbox-auth.js' +import { resolveAgsSandboxTimeout } from '../src/sandbox/stateful-sandbox-ttl.js' +import { describeStatefulToolCustomConfiguration } from '../src/sandbox/ensure-stateful-tool.js' +import { + mergeInstanceEnvIntoToolConfiguration, + pickStartCustomConfigurationFromTool, +} from '../src/sandbox/stateful-custom-configuration.js' +import { agsCredentialsFromProcessEnv, callAgsManagerApi } from '../src/lib/cloudbase-ags-api.js' + +const here = dirname(fileURLToPath(import.meta.url)) +config({ path: resolve(here, '../../../.env.local') }) + +async function callAgs(action: string, param: Record) { + return callAgsManagerApi(action, param, agsCredentialsFromProcessEnv()) +} + +async function main() { + const toolId = process.env.STATEFUL_TOOL_ID || '' + if (!toolId) throw new Error('STATEFUL_TOOL_ID required') + + const list = (await callAgs('DescribeSandboxToolList', { ToolIds: [toolId] })) as Record + const tool = (list.SandboxToolSet as Array> | undefined)?.[0] + console.log('=== Tool CustomConfiguration ===') + console.log(JSON.stringify(tool?.CustomConfiguration, null, 2)) + + const withGitEnv = process.env.WITH_GIT_ENV === '1' + const withMergedCfg = process.env.WITH_MERGED_CFG === '1' + const instanceEnv = buildGitArchiveInstanceEnv() + const toolCfg = withGitEnv || withMergedCfg ? await describeStatefulToolCustomConfiguration(toolId) : null + const mode = withGitEnv ? '(merged GIT_ARCHIVE Env)' : withMergedCfg ? '(merged template, no Env)' : '(plain)' + console.log('\n=== StartSandboxInstance ===', mode) + if (withGitEnv) console.log('Env keys:', instanceEnv.map((e) => e.Name).join(', ')) + try { + let customConfiguration: Record | undefined + if (toolCfg && withGitEnv && instanceEnv.length > 0) { + customConfiguration = mergeInstanceEnvIntoToolConfiguration(toolCfg, instanceEnv) + } else if (toolCfg && withMergedCfg) { + customConfiguration = pickStartCustomConfigurationFromTool(toolCfg) + } + const startParam: Record = { + ToolId: toolId, + Timeout: resolveAgsSandboxTimeout(), + ...(customConfiguration ? { CustomConfiguration: customConfiguration } : {}), + } + if (!isStatefulAuthModeEnabled()) startParam.AuthMode = 'NONE' + const resp = await callAgs('StartSandboxInstance', startParam) + console.log('OK:', JSON.stringify(resp, null, 2)) + } catch (err) { + const e = err as Error & { code?: string; requestId?: string; data?: unknown } + console.error('message:', e.message) + console.error('code:', e.code) + console.error('requestId:', e.requestId) + if (e.data) console.error('data:', JSON.stringify(e.data, null, 2)) + console.error('keys:', Object.keys(e)) + console.error('full:', JSON.stringify(err, Object.getOwnPropertyNames(err as object), 2)) + process.exit(1) + } +} + +main() diff --git a/packages/server/scripts/describe-stateful-tool.ts b/packages/server/scripts/describe-stateful-tool.ts new file mode 100644 index 0000000..f8b6d90 --- /dev/null +++ b/packages/server/scripts/describe-stateful-tool.ts @@ -0,0 +1,52 @@ +/** + * Print AGS tool ports/image for STATEFUL_TOOL_ID. + * + * pnpm exec tsx scripts/describe-stateful-tool.ts + */ + +import { config } from 'dotenv' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { agsCredentialsFromProcessEnv, callAgsManagerApi } from '../src/lib/cloudbase-ags-api.js' + +const here = dirname(fileURLToPath(import.meta.url)) +config({ path: resolve(here, '../../../.env.local') }) + +async function callAgs(action: string, param: Record) { + return callAgsManagerApi(action, param, agsCredentialsFromProcessEnv()) +} + +async function main() { + const toolId = process.env.STATEFUL_TOOL_ID || process.env.STATEFUL_SANDBOX_TOOL_ID || '' + if (!toolId) throw new Error('STATEFUL_TOOL_ID required') + + const resp = await callAgs('DescribeSandboxToolList', { ToolIds: [toolId] }) + const tool = (resp.SandboxToolSet as Array> | undefined)?.[0] + if (!tool) { + console.log('No tool found for', toolId) + process.exit(1) + } + + const cfg = tool.CustomConfiguration as Record | undefined + const ports = (cfg?.Ports as Array<{ Name?: string; Port?: number; Protocol?: string }>) || [] + console.log('ToolId:', tool.ToolId) + console.log('ToolName:', tool.ToolName) + console.log('Status:', tool.Status) + console.log('Image:', (cfg?.Image as string | undefined)?.slice(0, 120)) + console.log('Ports:') + for (const p of ports) { + console.log(` - ${p.Name ?? '?'}: ${p.Port} (${p.Protocol ?? 'TCP'})`) + } + const expected = [9000, 49983] + const actual = ports.map((p) => p.Port).filter((n): n is number => typeof n === 'number') + const extra = actual.filter((p) => !expected.includes(p)) + console.log('standard ports (9000+49983):', expected.every((p) => actual.includes(p)) ? 'ok' : 'MISSING') + if (extra.length > 0) { + console.log('extra declared ports (may break /preview/7681/):', extra.join(', ')) + } +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/packages/server/scripts/probe-gateway-port.ts b/packages/server/scripts/probe-gateway-port.ts new file mode 100644 index 0000000..758aa19 --- /dev/null +++ b/packages/server/scripts/probe-gateway-port.ts @@ -0,0 +1,61 @@ +/** + * Probe TCB gateway routing to a specific container port on an instance. + * + * SANDBOX_ID=l6wbb... PORT=5173 pnpm exec tsx scripts/probe-gateway-port.ts + * SANDBOX_ID=l6wbb... PORT=9000 PATH=/preview/5173/ pnpm exec tsx scripts/probe-gateway-port.ts + */ + +import { config } from 'dotenv' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + buildGatewayTarget, + buildTrwPreviewGatewayTarget, + gatewayFetch, + SANDBOX_BUSINESS_IMAGE_PORT, +} from '../src/sandbox/stateful/gateway.js' + +const here = dirname(fileURLToPath(import.meta.url)) +config({ path: resolve(here, '../../../.env.local') }) + +async function main() { + const sandboxId = process.env.SANDBOX_ID || process.env.STATEFUL_SANDBOX_ID || '' + const envId = process.env.TCB_ENV_ID || '' + const tcbApiKey = process.env.TCB_API_KEY || '' + const accessToken = process.env.TCB_ACCESS_TOKEN?.trim() || undefined + if (!sandboxId) throw new Error('SANDBOX_ID required') + if (!tcbApiKey || !envId) throw new Error('TCB_ENV_ID and TCB_API_KEY required') + + const port = Number(process.env.GW_PORT || '5173') + const path = process.env.GW_PATH || '/' + const mode = process.env.GW_MODE || 'direct' + + const target = + mode === 'trw-preview' + ? buildTrwPreviewGatewayTarget({ + envId, + sandboxId, + tcbApiKey, + vitePort: port, + subpath: path === '/' ? '/' : path, + accessToken, + }) + : buildGatewayTarget({ envId, sandboxId, tcbApiKey, port, path, accessToken }) + + console.log('target.url', target.url) + console.log('E2b-Sandbox-Port', target.port) + const res = await gatewayFetch(target, { signal: AbortSignal.timeout(30_000) }) + const ct = res.headers.get('content-type') ?? '' + const enc = res.headers.get('content-encoding') ?? '(none)' + const snippet = (await res.text()).slice(0, 200).replace(/\s+/g, ' ') + console.log('status', res.status, 'content-type', ct, 'content-encoding', enc) + console.log('body', snippet) + if (port !== SANDBOX_BUSINESS_IMAGE_PORT && res.status === 500) { + console.log('\nHint: port not declared on AGS tool — run describe-stateful-tool.ts') + } +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/packages/server/scripts/stop-stateful-instances.ts b/packages/server/scripts/stop-stateful-instances.ts new file mode 100644 index 0000000..fc87640 --- /dev/null +++ b/packages/server/scripts/stop-stateful-instances.ts @@ -0,0 +1,100 @@ +/** + * Stop active AGS sandbox instances for the configured stateful tool (shared-env cleanup). + * Loads repo root .env.local (see scripts/lib/env-files.mjs). + * + * Usage: pnpm stop:stateful-instances + */ +import { readFileSync, existsSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { agsCredentialsFromProcessEnv, callAgsManagerApi } from '../src/lib/cloudbase-ags-api.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const envPath = resolve(__dirname, '../../../.env.local') +if (existsSync(envPath)) { + for (const line of readFileSync(envPath, 'utf8').split('\n')) { + const t = line.trim() + if (!t || t.startsWith('#')) continue + const eq = t.indexOf('=') + if (eq <= 0) continue + const k = t.slice(0, eq).trim() + const v = t.slice(eq + 1).trim() + if (!process.env[k]) process.env[k] = v + } +} + +const ACTIVE = new Set(['RUNNING', 'PAUSED', 'RESUME_FAILED']) + +async function ags(action: string, param: Record) { + return callAgsManagerApi(action, param, agsCredentialsFromProcessEnv()) +} + +async function resolveToolId(): Promise { + const override = process.env.STATEFUL_TOOL_ID || process.env.STATEFUL_SANDBOX_TOOL_ID || '' + if (override) return override + + const envId = process.env.TCB_ENV_ID || '' + if (!envId) { + throw new Error('Set STATEFUL_TOOL_ID or TCB_ENV_ID in .env.local') + } + + const { statefulToolNameForEnv } = await import('../src/sandbox/ensure-stateful-tool.js') + const toolName = statefulToolNameForEnv(envId) + const resp = await ags('DescribeSandboxToolList', { + Filters: [{ Name: 'ToolName', Values: [toolName] }], + Limit: 20, + }) + const set = (resp.SandboxToolSet || (resp.data as Record | undefined)?.SandboxToolSet) as + | Array> + | undefined + const hit = Array.isArray(set) ? set.find((t) => t.ToolName === toolName && typeof t.ToolId === 'string') : undefined + if (hit?.ToolId) return hit.ToolId as string + + throw new Error(`No AGS tool found for ToolName=${toolName} (env ${envId})`) +} + +async function main() { + const toolId = await resolveToolId() + + const list = await ags('DescribeSandboxInstanceList', { ToolId: toolId, Limit: 100 }) + const data = list?.data as Record | undefined + const rows = (list?.InstanceSet || data?.InstanceSet || []) as Array> + const active = rows + .map((it) => ({ + instanceId: String(it.InstanceId || ''), + status: String(it.Status || ''), + })) + .filter((it) => it.instanceId && ACTIVE.has(it.status)) + + if (active.length === 0) { + console.log(JSON.stringify({ ok: true, toolId, stopped: 0, message: 'no active instances' })) + return + } + + const stopped: string[] = [] + const errors: Array<{ instanceId: string; error: string }> = [] + for (const { instanceId, status } of active) { + try { + await ags('StopSandboxInstance', { InstanceId: instanceId }) + stopped.push(`${instanceId}(${status})`) + } catch (e) { + errors.push({ instanceId, error: (e as Error).message }) + } + } + + console.log( + JSON.stringify({ + ok: errors.length === 0, + toolId, + stopped: stopped.length, + instances: stopped, + errors: errors.length ? errors : undefined, + }), + ) + if (errors.length) process.exit(1) +} + +main().catch((e) => { + console.error((e as Error).message) + process.exit(1) +}) diff --git a/packages/server/scripts/test-acp-chat-http.mts b/packages/server/scripts/test-acp-chat-http.mts index 182753a..b67a77c 100644 --- a/packages/server/scripts/test-acp-chat-http.mts +++ b/packages/server/scripts/test-acp-chat-http.mts @@ -8,7 +8,7 @@ * 走完整 SSE 流,验证 runtime=opencode-acp 时整条链路的 HTTP 行为。 * * 用法(packages/server 目录): - * npx tsx --env-file=.env scripts/test-acp-chat-http.mts + * npx tsx --env-file=../../.env.local scripts/test-acp-chat-http.mts */ import 'dotenv/config' diff --git a/packages/server/scripts/test-acp-http-e2e.mts b/packages/server/scripts/test-acp-http-e2e.mts index 05c5850..e37fd2a 100644 --- a/packages/server/scripts/test-acp-http-e2e.mts +++ b/packages/server/scripts/test-acp-http-e2e.mts @@ -24,7 +24,7 @@ process.env.PORT = String(PORT) // 通过 dotenv 加载 .env import { config } from 'dotenv' -config({ path: '.env' }) +config({ path: '../../.env.local' }) console.log('[e2e-http] booting server...') diff --git a/packages/server/scripts/test-memory-flow-e2e.mts b/packages/server/scripts/test-memory-flow-e2e.mts index dd743ce..1de8443 100644 --- a/packages/server/scripts/test-memory-flow-e2e.mts +++ b/packages/server/scripts/test-memory-flow-e2e.mts @@ -23,7 +23,7 @@ * - Turn 3 assistant 的 parts 含 tool_call(completed) + tool_result(completed) * * 用法: - * npx tsx --env-file=.env scripts/test-memory-flow-e2e.mts + * npx tsx --env-file=../../.env.local scripts/test-memory-flow-e2e.mts */ import 'dotenv/config' diff --git a/packages/server/scripts/test-opencode-coding-preview-e2e.mts b/packages/server/scripts/test-opencode-coding-preview-e2e.mts index 6be6922..c699055 100644 --- a/packages/server/scripts/test-opencode-coding-preview-e2e.mts +++ b/packages/server/scripts/test-opencode-coding-preview-e2e.mts @@ -1,5 +1,9 @@ #!/usr/bin/env tsx /** + * 【过时】上游 main 保留的 SCF 环境 e2e,feature/stateful-infra 分支请勿运行。 + * 依赖 `scf-sandbox-manager`,仅适用于 SCF 沙箱;AGS + 沙箱业务镜像请用 verify-stateful-e2e.ts。 + * Stateful: `npx tsx scripts/verify-stateful-e2e.ts` (loads ../../.env.local) + * * E2E: OpenCode runtime + coding mode + preview + 第二轮修改 * * 验证目标: @@ -10,7 +14,7 @@ * * 用法: * cd packages/server - * npx tsx --env-file=.env scripts/test-opencode-coding-preview-e2e.mts + * npx tsx --env-file=../../.env.local scripts/test-opencode-coding-preview-e2e.mts */ import 'dotenv/config' diff --git a/packages/server/scripts/test-opencode-sandbox-e2e.mts b/packages/server/scripts/test-opencode-sandbox-e2e.mts index a9410f5..c500d5b 100644 --- a/packages/server/scripts/test-opencode-sandbox-e2e.mts +++ b/packages/server/scripts/test-opencode-sandbox-e2e.mts @@ -1,5 +1,9 @@ #!/usr/bin/env tsx /** + * 【过时】上游 main 保留的 SCF 环境 e2e,feature/stateful-infra 分支请勿运行。 + * 依赖 `scf-sandbox-manager`,仅适用于 SCF 沙箱;AGS + 沙箱业务镜像请用 verify-stateful-e2e.ts。 + * Stateful: `npx tsx scripts/verify-stateful-e2e.ts` (loads ../../.env.local) + * * 沙箱隔离 e2e 测试(新架构 - tool override + env 注入) * * 验证链路: @@ -14,7 +18,7 @@ * 3. 本地文件系统未被污染 * * 用法: - * npx tsx --env-file=.env scripts/test-opencode-sandbox-e2e.mts + * npx tsx --env-file=../../.env.local scripts/test-opencode-sandbox-e2e.mts */ import 'dotenv/config' diff --git a/packages/server/scripts/test-persistence-e2e.mts b/packages/server/scripts/test-persistence-e2e.mts index d602f58..21ccf5d 100644 --- a/packages/server/scripts/test-persistence-e2e.mts +++ b/packages/server/scripts/test-persistence-e2e.mts @@ -17,7 +17,7 @@ * E. 前端 tasks.ts 转换逻辑能消化(模拟: 转成 TaskMessage 不报错) * * 用法: - * npx tsx --env-file=.env scripts/test-persistence-e2e.mts + * npx tsx --env-file=../../.env.local scripts/test-persistence-e2e.mts */ import 'dotenv/config' diff --git a/packages/server/scripts/test-persistence-suspend-e2e.mts b/packages/server/scripts/test-persistence-suspend-e2e.mts index 46e1906..e6b2c4a 100644 --- a/packages/server/scripts/test-persistence-suspend-e2e.mts +++ b/packages/server/scripts/test-persistence-suspend-e2e.mts @@ -16,7 +16,7 @@ * assistant.status = 'done' * * 用法: - * npx tsx --env-file=.env scripts/test-persistence-suspend-e2e.mts + * npx tsx --env-file=../../.env.local scripts/test-persistence-suspend-e2e.mts */ import 'dotenv/config' diff --git a/packages/server/scripts/test-runtime-selector.mts b/packages/server/scripts/test-runtime-selector.mts index 8fa087b..b40e679 100644 --- a/packages/server/scripts/test-runtime-selector.mts +++ b/packages/server/scripts/test-runtime-selector.mts @@ -11,7 +11,7 @@ * 不依赖真实 LLM —— 只验证路由层,不等 agent 跑完。 * * 用法: - * npx tsx --env-file=.env scripts/test-runtime-selector.mts + * npx tsx --env-file=../../.env.local scripts/test-runtime-selector.mts */ import 'dotenv/config' diff --git a/packages/server/scripts/update-stateful-tool-image.ts b/packages/server/scripts/update-stateful-tool-image.ts new file mode 100644 index 0000000..c557a0d --- /dev/null +++ b/packages/server/scripts/update-stateful-tool-image.ts @@ -0,0 +1,56 @@ +/** + * Point an existing stateful SDT at a new container image (after 沙箱业务镜像 rebuild). + * + * Usage (repo root .env.local loaded): + * STATEFUL_TOOL_ID=sdt-xxx STATEFUL_SANDBOX_IMAGE=ccr.../tcb-sandbox-ags:app-vibecoding \ + * pnpm exec tsx scripts/update-stateful-tool-image.ts + */ + +import { config } from 'dotenv' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { agsCredentialsFromProcessEnv, callAgsManagerApi } from '../src/lib/cloudbase-ags-api.js' + +const here = dirname(fileURLToPath(import.meta.url)) +config({ path: resolve(here, '../../../.env.local') }) + +async function callAgs(action: string, param: Record) { + return callAgsManagerApi(action, param, agsCredentialsFromProcessEnv()) +} + +async function main() { + const toolId = process.env.STATEFUL_TOOL_ID || process.env.STATEFUL_SANDBOX_TOOL_ID || '' + const image = process.env.STATEFUL_SANDBOX_IMAGE || '' + if (!toolId) throw new Error('STATEFUL_TOOL_ID required') + if (!image) throw new Error('STATEFUL_SANDBOX_IMAGE required (full image URI after push)') + + const param = { + ToolId: toolId, + CustomConfiguration: { + Image: image, + ImageRegistryType: process.env.STATEFUL_IMAGE_REGISTRY || 'personal', + }, + } + + const { STATEFUL_TOOL_WARMUP_POLL_MS, STATEFUL_TOOL_WARMUP_POLL_MAX } = + await import('../src/sandbox/stateful-tool-warmup.js') + + for (const action of ['UpdateSandboxTool', 'ModifySandboxTool'] as const) { + try { + const resp = await callAgs(action, param) + console.log(`[update-stateful-tool] ${action} ok:`, JSON.stringify(resp).slice(0, 400)) + console.log( + `[update-stateful-tool] Poll ${STATEFUL_TOOL_WARMUP_POLL_MAX}×${STATEFUL_TOOL_WARMUP_POLL_MS / 1000}s before StartSandboxInstance (AGS image pull window).`, + ) + return + } catch (err) { + console.warn(`[update-stateful-tool] ${action} failed:`, (err as Error).message) + } + } + throw new Error('Neither UpdateSandboxTool nor ModifySandboxTool succeeded') +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/packages/server/scripts/update-stateful-tool-ports.ts b/packages/server/scripts/update-stateful-tool-ports.ts new file mode 100644 index 0000000..fd9bfa8 --- /dev/null +++ b/packages/server/scripts/update-stateful-tool-ports.ts @@ -0,0 +1,89 @@ +/** + * Set AGS tool Ports to TRW + envd only (idempotent). + * + * STATEFUL_TOOL_ID=... pnpm exec tsx scripts/update-stateful-tool-ports.ts + */ + +import { config } from 'dotenv' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { agsCredentialsFromProcessEnv, callAgsManagerApi } from '../src/lib/cloudbase-ags-api.js' + +const here = dirname(fileURLToPath(import.meta.url)) +config({ path: resolve(here, '../../../.env.local') }) + +/** Match ensure-stateful-tool.ts — preview via :9000, envd for e2b SDK. */ +const STANDARD_TOOL_PORTS = [ + { Name: 'trw', Protocol: 'TCP', Port: 9000 }, + { Name: 'envd', Protocol: 'TCP', Port: 49983 }, +] + +async function callAgs(action: string, param: Record) { + return callAgsManagerApi(action, param, agsCredentialsFromProcessEnv()) +} + +async function resolveToolId(): Promise { + const fromEnv = process.env.STATEFUL_TOOL_ID || process.env.STATEFUL_SANDBOX_TOOL_ID || '' + if (fromEnv) return fromEnv + + const envId = process.env.TCB_ENV_ID || '' + if (!envId) throw new Error('TCB_ENV_ID required') + + const { getDb } = await import('../src/db/index.js') + const { getProvisionMode } = await import('../src/lib/provision-config.js') + const { STATEFUL_TOOL_SETTINGS_KEY, statefulToolNameForEnv } = await import('../src/sandbox/ensure-stateful-tool.js') + + if ((await getProvisionMode()) === 'shared') { + const row = await getDb().settings.findSystemSetting(STATEFUL_TOOL_SETTINGS_KEY) + if (row?.value) return row.value + } + + const toolName = statefulToolNameForEnv(envId) + const list = (await callAgs('DescribeSandboxToolList', { + Filters: [{ Name: 'ToolName', Values: [toolName] }], + Limit: 20, + })) as { SandboxToolSet?: Array<{ ToolId?: string; ToolName?: string }> } + const hit = list.SandboxToolSet?.find((t) => t.ToolName === toolName && t.ToolId) + if (hit?.ToolId) return hit.ToolId + + throw new Error('No tool id in env or DB; set STATEFUL_TOOL_ID or run ensureStatefulTool once') +} + +async function main() { + const toolId = await resolveToolId() + + const list = (await callAgs('DescribeSandboxToolList', { ToolIds: [toolId] })) as { + SandboxToolSet?: Array<{ CustomConfiguration?: { Ports?: Array<{ Port?: number }>; Image?: string } }> + } + const tool = list.SandboxToolSet?.[0] + if (!tool?.CustomConfiguration) throw new Error('Tool not found') + + const cfg = tool.CustomConfiguration + const before = (cfg.Ports || []).map((p) => p.Port).filter((n): n is number => typeof n === 'number') + const param = { + ToolId: toolId, + CustomConfiguration: { + Image: cfg.Image, + ImageRegistryType: process.env.STATEFUL_IMAGE_REGISTRY || 'personal', + Ports: STANDARD_TOOL_PORTS, + }, + } + + for (const action of ['UpdateSandboxTool', 'ModifySandboxTool'] as const) { + try { + const resp = await callAgs(action, param) + console.log(`[update-stateful-tool-ports] ${action} ok`, JSON.stringify(resp).slice(0, 400)) + console.log('Ports before:', before.join(', ') || '(none)') + console.log('Ports now:', STANDARD_TOOL_PORTS.map((p) => p.Port).join(', ')) + return + } catch (err) { + console.warn(`[update-stateful-tool-ports] ${action} failed:`, (err as Error).message) + } + } + throw new Error('Update failed') +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/packages/server/scripts/verify-stateful-e2e.ts b/packages/server/scripts/verify-stateful-e2e.ts new file mode 100644 index 0000000..2c441b1 --- /dev/null +++ b/packages/server/scripts/verify-stateful-e2e.ts @@ -0,0 +1,174 @@ +/** + * OpenVibeCoding stateful sandbox acceptance (vibecoding tool). + * Emits one JSON line per probe for CI / agent parsing. + * + * Usage (from packages/server): + * pnpm verify:stateful + */ +import { readFileSync, existsSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const envPath = resolve(__dirname, '../../../.env.local') +if (existsSync(envPath)) { + for (const line of readFileSync(envPath, 'utf8').split('\n')) { + const t = line.trim() + if (!t || t.startsWith('#')) continue + const eq = t.indexOf('=') + if (eq <= 0) continue + const k = t.slice(0, eq).trim() + const v = t.slice(eq + 1).trim() + if (!process.env[k]) process.env[k] = v + } +} + +import { getSandboxProvider } from '../src/sandbox/provider/factory.js' +import { + statefulReadTextFile, + statefulRunCommand, + resolveStatefulFilePath, +} from '../src/sandbox/stateful/e2b-native-client.js' + +const VITE_PORT = 5173 +const PROBE_PREFIX = 'ovc_stateful_probe' + +type ProbeResult = { + probe: string + ok: boolean + detail?: string + ms?: number +} + +const results: ProbeResult[] = [] + +function emit(probe: string, ok: boolean, detail?: string, ms?: number) { + const row: ProbeResult = { probe, ok, ...(detail ? { detail } : {}), ...(ms !== undefined ? { ms } : {}) } + results.push(row) + console.log(JSON.stringify({ type: PROBE_PREFIX, ...row })) +} + +async function trwBash( + inst: Awaited['acquire']>>, + command: string, +): Promise<{ exitCode: number; output: string }> { + const res = await inst.request('/api/tools/bash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command, timeout: 60_000 }), + }) + const data = (await res.json()) as { + success?: boolean + result?: { output?: string; exitCode?: number } + error?: string + } + if (!data.success) { + return { exitCode: 1, output: data.error || `bash http ${res.status}` } + } + return { + exitCode: data.result?.exitCode ?? 1, + output: data.result?.output || '', + } +} + +async function main() { + const t0 = Date.now() + const envId = process.env.TCB_ENV_ID || 'openvibecoding-verify-env' + const conversationId = `openvibecoding-verify-${Date.now()}` + const provider = getSandboxProvider() + + emit('config_tool_id', !!(process.env.STATEFUL_TOOL_ID || process.env.STATEFUL_SANDBOX_TOOL_ID)) + emit('config_gateway_url', !!process.env.TCB_ENV_ID) + emit('config_tcb_api_key', !!process.env.TCB_API_KEY) + + let inst: Awaited> + try { + const tAcquire = Date.now() + inst = await provider.acquire({ + envId, + conversationId, + backendOptions: { backend: 'stateful' }, + }) + emit('acquire_instance', !!inst.id, inst.id, Date.now() - tAcquire) + } catch (e) { + emit('acquire_instance', false, (e as Error).message) + process.exit(1) + } + + try { + const tInit = Date.now() + const session = await provider.prepare(inst, { + credentials: { + envId: process.env.TCB_ENV_ID || '', + secretId: process.env.TCB_SECRET_ID || '', + secretKey: process.env.TCB_SECRET_KEY || '', + }, + codingMode: true, + backendOptions: { backend: 'stateful' }, + }) + emit('workspace_init', !!session.workspace, session.workspace, Date.now() - tInit) + } catch (e) { + emit('workspace_init', false, (e as Error).message) + } + + const trwPwd = await trwBash(inst, 'pwd') + emit( + 'trw_bash_pwd', + trwPwd.exitCode === 0 && trwPwd.output.trim() === '/home/user', + trwPwd.output.trim() || `exit ${trwPwd.exitCode}`, + ) + + const trwPkg = await trwBash(inst, 'test -f package.json && echo yes || echo no') + emit('trw_package_json', trwPkg.output.trim() === 'yes', trwPkg.output.trim()) + + const envdPwd = await statefulRunCommand(inst, 'pwd') + emit( + 'envd_command_pwd', + envdPwd.exitCode === 0 && envdPwd.stdout.trim() === '/home/user', + envdPwd.exitCode === 0 ? envdPwd.stdout.trim() : envdPwd.stderr || `exit ${envdPwd.exitCode}`, + ) + + for (const filePath of ['package.json', '/home/user/package.json', 'home/user/package.json']) { + const text = await statefulReadTextFile(inst, filePath) + const ok = !!text && text.includes('cloudbase-react-template') + emit(`envd_read_${resolveStatefulFilePath(filePath).replace(/[^\w]+/g, '_') || 'root'}`, ok, filePath) + } + + try { + const { buildPreviewGatewayHeaders } = await import('../src/sandbox/stateful/gateway.js') + const headers = buildPreviewGatewayHeaders(inst, VITE_PORT) + const previewRes = await fetch(`${inst.baseUrl}/`, { + method: 'GET', + headers, + signal: AbortSignal.timeout(15_000), + }) + const body = await previewRes.text() + emit( + 'gateway_vite_root', + previewRes.status >= 200 && previewRes.status < 400 && body.length > 0, + `status=${previewRes.status} bytes=${body.length}`, + ) + } catch (e) { + emit('gateway_vite_root', false, (e as Error).message) + } + + const healthRes = await inst.request('/health', { signal: AbortSignal.timeout(10_000) }) + emit('trw_health', healthRes.ok, `status=${healthRes.status}`) + + const failed = results.filter((r) => !r.ok) + emit( + 'summary', + failed.length === 0, + `${results.length - failed.length}/${results.length} passed in ${Date.now() - t0}ms`, + ) + + if (failed.length > 0) { + console.error('Failed probes:', failed.map((f) => f.probe).join(', ')) + process.exit(1) + } +} + +main().catch((e) => { + emit('fatal', false, (e as Error).message) + process.exit(1) +}) diff --git a/packages/server/src/agent/cloudbase-agent.service.ts b/packages/server/src/agent/cloudbase-agent.service.ts index 651fc72..e7af5c7 100644 --- a/packages/server/src/agent/cloudbase-agent.service.ts +++ b/packages/server/src/agent/cloudbase-agent.service.ts @@ -1,23 +1,35 @@ import { mkdirSync, writeFileSync, readFileSync, appendFileSync, existsSync, unlinkSync } from 'node:fs' +import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' import { query, ExecutionError } from '@tencent-ai/agent-sdk' import { v4 as uuidv4 } from 'uuid' import { loadConfig } from '../config/store.js' import { persistenceService } from './persistence.service.js' -import { - scfSandboxManager, - type SandboxInstance, - type SandboxProgressCallback, -} from '../sandbox/scf-sandbox-manager.js' -import { createSandboxMcpClient } from '../sandbox/sandbox-mcp-proxy.js' +import { buildStatefulAcquireContext } from '../sandbox/acquire-context.js' +import { formatAgsManagerError, formatAgsUserFacingError } from '../sandbox/ags-error.js' +import { getSandboxProvider } from '../sandbox/index.js' +import type { + SandboxInstance, + SandboxProgressCallback, + McpClientBundle, + ToolOverrideConfig, +} from '../sandbox/provider/types.js' import { archiveToGit } from '../sandbox/git-archive.js' import { getCodingSystemPrompt } from './coding-mode.js' import { getDb } from '../db/index.js' -import { resolveSandboxConfig, backfillSandboxConfig } from '../lib/sandbox-config.js' +import { + STATEFUL_WORKSPACE_ROOT, + resolveSandboxConfig, + backfillSandboxConfig, + resolveAgentHostCwd, +} from '../lib/sandbox-config.js' import { decrypt } from '../lib/crypto.js' import { encryptJWE } from '../lib/session.js' import type { AgentCallbackMessage, AgentOptions, CodeBuddyMessage, ExtendedSessionUpdate } from '@coder/shared' +import { isSandboxToolName, sandboxLogMessageForTool, TASK_LOG } from '@coder/shared' +import { appendAllowedTaskLog } from '../lib/append-task-log.js' +import { createTaskLogger } from '../lib/task-logger.js' import { registerAgent, getAgentRun, completeAgent, isAgentRunning, type StopReason } from './agent-registry.js' import { EventBuffer } from './event-buffer.js' import { sessionPermissions, normalizeToolName } from './session-permissions.js' @@ -141,6 +153,7 @@ const SYSTEM_MODELS: ModelInfo[] = [ { id: 'kimi-k2.6', name: 'Kimi-K2.6' }, { id: 'minimax-m2.7', name: 'MiniMax-M2.7' }, { id: 'hunyuan-2.0-thinking', name: 'Hunyuan-2.0-Thinking' }, + { id: 'hy3-preview-ioa', name: 'Hunyuan-3 Preview (IOA)' }, { id: 'deepseek-v3-2-volc', name: 'DeepSeek-V3.2' }, ] @@ -182,8 +195,6 @@ export function clearModelsCache(): void { // ─── Sandbox & Prompt Helpers (imported from base-runtime) ─────────────── // 集中在 base-runtime.ts 维护,所有 runtime 共用。 import { - waitForSandboxHealth, - initSandboxWorkspace, WRITE_TOOLS, buildAppendPrompt, getPublishableKey, @@ -361,6 +372,14 @@ export class CloudbaseAgentService { timestamp: Date.now(), } as ExtendedSessionUpdate } + if (msg.type === 'log' && msg.content) { + return { + sessionUpdate: 'log', + level: msg.logLevel || 'info', + message: msg.content, + timestamp: Date.now(), + } as ExtendedSessionUpdate + } if (msg.type === 'artifact' && msg.artifact) { return { sessionUpdate: 'artifact', @@ -444,8 +463,25 @@ export class CloudbaseAgentService { }) // Launch agent in background (fire-and-forget) - this.launchAgent(prompt, callback, options, turnId).catch((err) => { - console.error('[Agent] Background agent error:', err) + this.launchAgent(prompt, callback, options, turnId).catch(async (err) => { + const message = (err as Error).message || String(err) + console.error('[Agent] Background agent error:', message) + if (isAgentRunning(conversationId)) { + completeAgent(conversationId, 'error', message, 'refusal') + } + try { + await getDb().tasks.update(conversationId, { + status: 'error', + error: message.slice(0, 500), + updatedAt: Date.now(), + }) + } catch { + // non-critical + } + callback?.({ + type: 'text', + content: `【Agent 启动失败】${message}\n`, + }) }) return { turnId, alreadyRunning: false } @@ -516,16 +552,15 @@ export class CloudbaseAgentService { conversationId, { sandboxMode: taskRecord?.sandboxMode, - sandboxSessionId: taskRecord?.sandboxSessionId, sandboxCwd: taskRecord?.sandboxCwd, }, userContext.envId, getDb(), ) + const refreshed = await getDb().tasks.findById(conversationId) sandboxConfig = resolveSandboxConfig({ - sandboxMode: taskRecord?.sandboxMode, - sandboxSessionId: taskRecord?.sandboxSessionId, - sandboxCwd: taskRecord?.sandboxCwd, + sandboxCwd: refreshed?.sandboxCwd ?? taskRecord?.sandboxCwd, + sandboxMode: refreshed?.sandboxMode ?? taskRecord?.sandboxMode, envId: userContext.envId, taskId: conversationId, }) @@ -538,22 +573,27 @@ export class CloudbaseAgentService { sandboxConfig = resolveSandboxConfig({ envId: userContext.envId, taskId: conversationId }) } - const { sandboxMode, sandboxSessionId, sandboxCwd: resolvedCwd } = sandboxConfig - const actualCwd = cwd || resolvedCwd - console.log( - `[Agent] sandboxConfig: mode=${sandboxMode}, sessionId=${sandboxSessionId}, resolvedCwd=${resolvedCwd}, cwd=${cwd}, actualCwd=${actualCwd}`, - ) - mkdirSync(actualCwd, { recursive: true }) + const taskForAcquire = await getDb() + .tasks.findById(conversationId) + .catch(() => null) + + const { sandboxCwd: resolvedCwd } = sandboxConfig + // Remote 沙箱业务镜像 workspace path (semantic only on stateful; tools run in sandbox via MCP). + const workspaceCwd = cwd || resolvedCwd + // CodeBuddy SDK runs on the OpenVibeCoding 服务端主机 — never mkdir/query against /home/user on macOS. + const localCwd = resolveAgentHostCwd(workspaceCwd, conversationId) + console.log(`[Agent] sandboxConfig: workspaceCwd=${workspaceCwd}, localCwd=${localCwd}, cwd=${cwd ?? '(none)'}`) + mkdirSync(localCwd, { recursive: true }) // ── 复制 .codebuddy/models.json 模板供 SDK 读取自定义模型 ──────────── // 仅当 CODEBUDDY_USE_CUSTOM_MODELS=true 时启用 if (useCustomModels()) { try { - const modelsJsonPath = path.join(actualCwd, '.codebuddy', 'models.json') + const modelsJsonPath = path.join(localCwd, '.codebuddy', 'models.json') if (!existsSync(modelsJsonPath)) { const templatePath = getModelsTemplatePath() if (templatePath) { - mkdirSync(path.join(actualCwd, '.codebuddy'), { recursive: true }) + mkdirSync(path.join(localCwd, '.codebuddy'), { recursive: true }) const raw = readFileSync(templatePath, 'utf-8') writeFileSync(modelsJsonPath, resolveEnvPlaceholders(raw), 'utf-8') console.log('[Agent] models.json written to cwd:', modelsJsonPath, 'from template:', templatePath) @@ -569,7 +609,7 @@ export class CloudbaseAgentService { } else { // 系统模型模式:清理掉旧的 models.json,避免 SDK 误读 try { - const modelsJsonPath = path.join(actualCwd, '.codebuddy', 'models.json') + const modelsJsonPath = path.join(localCwd, '.codebuddy', 'models.json') if (existsSync(modelsJsonPath)) { unlinkSync(modelsJsonPath) console.log('[Agent] CODEBUDDY_USE_CUSTOM_MODELS=false, removed stale models.json') @@ -593,7 +633,7 @@ export class CloudbaseAgentService { let historicalMessages: CodeBuddyMessage[] = [] let lastRecordId: string | null = null let hasHistory = false - let sandboxMcpClient: Awaited> | null = null + let sandboxMcpClient: McpClientBundle | null = null // askAnswers / toolConfirmation 场景标记为 resume const isResumeFromInterrupt = (askAnswers && Object.keys(askAnswers).length > 0) || !!toolConfirmation @@ -695,7 +735,7 @@ export class CloudbaseAgentService { // DEBUG: ACP SSE event log (enabled via AGENT_DEBUG_JSONL=1) let debugAcpLogPath: string | null = null if (DEBUG_JSONL) { - const debugAcpLogDir = path.resolve(actualCwd, 'debug-jsonl') + const debugAcpLogDir = path.resolve(localCwd, 'debug-jsonl') mkdirSync(debugAcpLogDir, { recursive: true }) debugAcpLogPath = path.join(debugAcpLogDir, `${conversationId}_acp_${Date.now()}.jsonl`) } @@ -740,17 +780,27 @@ export class CloudbaseAgentService { } } - // ── 获取 SCF 沙箱 ──────────────────────────────────────────────── + const taskLogger = createTaskLogger(conversationId) + taskLogger.registerACPNotifier((update) => { + if (update.sessionUpdate !== 'log') return + const seq = eventBuffer.pushAndGetSeq(update) + if (liveCallback) { + liveCallback({ type: 'log', content: update.message, logLevel: update.level }, seq) + } + }) + + // ── 获取 stateful 沙箱 ─────────────────────────────────────────── let sandboxInstance: SandboxInstance | null = null - let toolOverrideConfig: { url: string; headers: Record } | null = null + let toolOverrideConfig: ToolOverrideConfig | null = null let detectedSandboxCwd: string | undefined - const sandboxEnabled = - process.env.TCB_ENV_ID && (process.env.SANDBOX_IMAGE_URI || process.env.SCF_SANDBOX_IMAGE_URI) + const provider = getSandboxProvider() + const sandboxEnabled = !!process.env.TCB_ENV_ID && !!process.env.TCB_API_KEY // P4: 代理阶段上报助手 —— 在关键边界向前端透传当前状态 // 去重:只在 phase 或 toolName 变化时 emit,避免密集的 tool_use stream_event 刷屏 let lastEmittedPhase: { phase: string; toolName?: string } | null = null + let lastSandboxPrepareTool: string | undefined const emitPhase = ( phase: 'preparing' | 'model_responding' | 'tool_executing' | 'compacting' | 'idle', toolName?: string, @@ -758,12 +808,17 @@ export class CloudbaseAgentService { if (lastEmittedPhase && lastEmittedPhase.phase === phase && lastEmittedPhase.toolName === toolName) return lastEmittedPhase = { phase, toolName } wrappedCallback({ type: 'agent_phase', phase, phaseToolName: toolName }) - } - // 首个 phase:准备阶段(沙箱启动、工作空间初始化、历史恢复全部发生在 query() 之前) - emitPhase('preparing') + if (isSandboxToolName(toolName)) { + const message = sandboxLogMessageForTool(toolName!, { previousPrepareTool: lastSandboxPrepareTool }) + if (message) void taskLogger.info(message) + if (toolName !== 'sandbox:ready' && toolName !== 'sandbox:error') { + lastSandboxPrepareTool = toolName + } + } + } - // 沙箱子阶段 → emitPhase('preparing', 'sandbox:xxx') 桥接 + // 沙箱子阶段 → emitPhase('preparing', 'sandbox:xxx') 桥接(勿先发无 toolName 的 preparing,否则会盖住复用/启动等细粒度文案) // 让前端 AgentStatusIndicator 能在长耗时的沙箱创建流程中持续看到细粒度进度 const sandboxProgressBridge: SandboxProgressCallback = ({ phase }) => { emitPhase('preparing', `sandbox:${phase}`) @@ -771,25 +826,18 @@ export class CloudbaseAgentService { if (sandboxEnabled) { try { - sandboxInstance = await scfSandboxManager.getOrCreate( - conversationId, - userContext.envId, - { - mode: 'shared', - workspaceIsolation: sandboxMode as 'shared' | 'isolated', - sandboxSessionId, - isCodingMode, - }, + sandboxInstance = await provider.acquire( + buildStatefulAcquireContext({ + envId: userContext.envId, + taskId: conversationId, + userId: userContext.userId, + sandboxMode: sandboxConfig?.sandboxMode, + sandboxId: taskForAcquire?.sandboxId, + }), sandboxProgressBridge, ) - toolOverrideConfig = await sandboxInstance.getToolOverrideConfig() - - // ── 注入静态托管预签名配置(用于 ImageGen 上传)── - // tool-override 运行在 CLI 子进程,没有 session cookie。 - // 这里用 encryptJWE 签发一个短期 session JWE(同 nex_session 格式), - // 让 tool-override fetch 时带上 Cookie: nex_session=, - // 这样 server 侧 authMiddleware 直接走 session 认证。 + let hosting: ToolOverrideConfig['hosting'] | undefined try { const user = await getDb().users.findById(userContext.userId) if (user) { @@ -804,59 +852,76 @@ export class CloudbaseAgentService { name: user.name || undefined, }, } - // 有效期与 agent 任务生命周期匹配(2h 足够) const sessionJwe = await encryptJWE(session, '2h') const serverPort = Number(process.env.PORT) || 3001 - ;(toolOverrideConfig as any).hosting = { + hosting = { presignUrl: `http://localhost:${serverPort}/api/storage/presign?bucketType=static`, sessionCookie: sessionJwe, sessionId: conversationId, } } } catch { - // hosting presign 失败不影响主流程,图片会 fallback 到沙箱存储 + // hosting presign optional } - // ── 健康检查:等待沙箱就绪 ────────────────────────────────── - const sandboxReady = await waitForSandboxHealth(sandboxInstance, sandboxProgressBridge) - if (!sandboxReady) { - wrappedCallback({ type: 'text', content: '沙箱启动超时,将使用受限模式继续对话。\n\n' }) - sandboxInstance = null - } else { - // ── 初始化工作空间:注入【登录用户凭证】────────────────── - const initResult = await initSandboxWorkspace( + toolOverrideConfig = await provider.getToolOverrideConfig(sandboxInstance, hosting) + + let prepared: Awaited> | null = null + try { + prepared = await provider.prepare( sandboxInstance, { - envId: userContext.envId, - secretId: userCredentials?.secretId || '', - secretKey: userCredentials?.secretKey || '', - token: userCredentials?.sessionToken, + credentials: { + envId: userContext.envId, + secretId: userCredentials?.secretId || '', + secretKey: userCredentials?.secretKey || '', + sessionToken: userCredentials?.sessionToken, + }, + workspaceHint: STATEFUL_WORKSPACE_ROOT, + codingMode: isCodingMode, + backendOptions: { backend: 'stateful' }, }, - conversationId, - resolvedCwd || undefined, sandboxProgressBridge, ) - if (initResult.workspace) { - detectedSandboxCwd = initResult.workspace - wrappedCallback({ type: 'session', sandboxCwd: initResult.workspace } as any) - console.log(`[Agent] Sandbox workspace initialized, cwd: ${initResult.workspace}`) + } catch (prepErr) { + console.error('[Agent] Sandbox prepare failed:', (prepErr as Error).message) + wrappedCallback({ type: 'text', content: '沙箱启动超时,将使用受限模式继续对话。\n\n' }) + sandboxInstance = null + } + + if (sandboxInstance && prepared) { + if (prepared.workspace) { + detectedSandboxCwd = prepared.workspace + wrappedCallback({ type: 'session', sandboxCwd: prepared.workspace } as any) + console.log(`[Agent] Sandbox workspace initialized, cwd: ${prepared.workspace}`) + try { + await getDb().tasks.update(conversationId, { + sandboxCwd: prepared.workspace, + sandboxMode: sandboxConfig?.sandboxMode ?? 'shared', + sandboxId: sandboxInstance.id, + updatedAt: Date.now(), + }) + } catch { + // non-critical + } } try { const { success } = await initRepo(sandboxInstance, conversationId) - if (success) { - console.log(`[Agent] Sandbox user repo initialized`) - } + if (success) console.log(`[Agent] Sandbox user repo initialized`) } catch (e) { console.log(`[Agent] Sandbox initialized user repo err: ${e}`) } - // Create sandbox MCP client,使用【登录用户凭证】操作 CloudBase 资源 - sandboxMcpClient = await createSandboxMcpClient({ + sandboxMcpClient = await provider.createMcpClient({ sandbox: sandboxInstance, - userId: userContext.userId, - envId: userContext.envId, - workspaceFolderPaths: actualCwd, + getCredentials: async () => ({ + cloudbaseEnvId: userContext.envId, + secretId: userCredentials?.secretId || '', + secretKey: userCredentials?.secretKey || '', + sessionToken: userCredentials?.sessionToken, + }), + workspaceFolderPaths: workspaceCwd, log: (msg) => console.log(msg), onArtifact: (artifact) => { wrappedCallback({ type: 'artifact', artifact }) @@ -866,32 +931,34 @@ export class CloudbaseAgentService { if (!app) return null return { appId: app.appId, privateKey: decrypt(app.privateKey) } }, + userId: userContext.userId, currentModel: modelId, }) console.log('[Agent] Sandbox ready') - // Persist sandboxId to task record so frontend can access file browser try { await getDb().tasks.update(conversationId, { - sandboxId: sandboxInstance.functionName, + sandboxId: sandboxInstance.id, }) } catch { - // Non-critical: file browser won't show but agent continues + // non-critical } } } catch (err) { - console.error('[Agent] Sandbox creation failed:', (err as Error).message) + const detail = formatAgsManagerError(err, 'sandbox.acquire') + const userDetail = formatAgsUserFacingError(err) + console.error('[Agent] Sandbox creation failed:', detail) wrappedCallback({ type: 'text', - content: `【沙箱环境创建失败】${(err as Error).message}。将使用受限模式继续对话。\n\n`, + content: `【沙箱环境创建失败】\n${userDetail}\n\n将使用受限模式继续对话。\n\n`, }) // Continue without sandbox } } // ── Coding mode: mark preview ready ───────────────────────────────────── - // 沙箱 /api/session/init 已内置完整的项目初始化流程: + // 沙箱业务镜像 POST /api/workspace/init handles workspace bootstrap in the stateful provider. // - seedCodingTemplate: 从内置模板复制(零延迟) // - ensureViteDev: 自动启动 vite dev server + crash 重启 // - node_modules 恢复: tar.gz 缓存 / npm install @@ -1061,7 +1128,7 @@ export class CloudbaseAgentService { conversationId, userContext.envId, userContext.userId, - actualCwd, + localCwd, ) historicalMessages = restored.messages lastRecordId = restored.lastRecordId @@ -1173,7 +1240,11 @@ export class CloudbaseAgentService { const publishableKey = await getPublishableKey(userContext.envId) // 构建 query 参数 - 和 tcb-headless-service buildQueryOptions 一致 - // 注意: cwd 必须是本地路径, 即使沙箱启用. 沙箱只提供 MCP 工具, agent 进程在本地运行. + // cwd=localCwd:仅 SDK 会话 JSONL 落盘;Bash/Read/Write 经 CODEBUDDY_TOOL_OVERRIDE 走远程 沙箱业务镜像。 + const appendPromptOpts = { + remoteToolsActive: !!toolOverrideConfig, + localHostCwd: localCwd, + } // 多模态 prompt:有图片时构建 ContentBlock[] 作为 UserMessage,否则直接用字符串 let queryPrompt: string | any @@ -1211,15 +1282,29 @@ export class CloudbaseAgentService { permissionMode: sdkPermissionMode, allowDangerouslySkipPermissions: sdkPermissionMode === 'bypassPermissions', maxTurns, - cwd: actualCwd, + cwd: localCwd, ...sessionOpts, includePartialMessages: true, systemPrompt: { append: isCodingMode ? getCodingSystemPrompt(userContext.envId, publishableKey) + '\n\n' + - buildAppendPrompt(actualCwd, conversationId, userContext.envId, sandboxMode, true) - : buildAppendPrompt(actualCwd, conversationId, userContext.envId, sandboxMode, false), + buildAppendPrompt( + workspaceCwd, + conversationId, + userContext.envId, + sandboxConfig.sandboxMode, + true, + appendPromptOpts, + ) + : buildAppendPrompt( + workspaceCwd, + conversationId, + userContext.envId, + sandboxConfig.sandboxMode, + false, + appendPromptOpts, + ), }, mcpServers, abortController, @@ -1480,7 +1565,7 @@ export class CloudbaseAgentService { // DEBUG: log all messages from messageLoop to a file (enabled via AGENT_DEBUG_JSONL=1) let debugMsgLogPath: string | null = null if (DEBUG_JSONL) { - const debugMsgLogDir = path.resolve(actualCwd, 'debug-jsonl') + const debugMsgLogDir = path.resolve(localCwd, 'debug-jsonl') mkdirSync(debugMsgLogDir, { recursive: true }) debugMsgLogPath = path.join(debugMsgLogDir, `${conversationId}_messageloop_${Date.now()}.jsonl`) } @@ -1832,7 +1917,12 @@ export class CloudbaseAgentService { } try { - await archiveToGit(sandboxInstance, conversationId, prompt) + const archiveResult = await archiveToGit(sandboxInstance, conversationId, prompt) + if (archiveResult === 'ok') { + void appendAllowedTaskLog(conversationId, 'info', TASK_LOG.PLATFORM_ARCHIVE_PUSH_OK) + } else if (archiveResult === 'fail') { + void appendAllowedTaskLog(conversationId, 'error', TASK_LOG.PLATFORM_ARCHIVE_PUSH_FAILED) + } } catch (err) { console.error('[Agent] Archive to git failed:', (err as Error).message) } @@ -1857,7 +1947,7 @@ export class CloudbaseAgentService { userContext.userId, historicalMessages, lastRecordId, - actualCwd, + localCwd, assistantMessageId, isResumeFromInterrupt, preSavedUserRecordId, diff --git a/packages/server/src/agent/coding-mode.ts b/packages/server/src/agent/coding-mode.ts index a452696..f5afda6 100644 --- a/packages/server/src/agent/coding-mode.ts +++ b/packages/server/src/agent/coding-mode.ts @@ -3,7 +3,7 @@ const DEV_SERVER_PORT = 5173 /** * The correct vite.config.ts content for CloudBase sandbox preview. * - base "./" for static hosting deployment (relative asset paths) - * - dev server is launched with --base=/preview/ CLI flag which overrides this + * - dev server is launched with --base=/ (沙箱业务镜像 vite-dev-manager) which overrides this for preview * - server.host "0.0.0.0" lets the CloudBase gateway proxy reach the dev server * - server.allowedHosts true allows requests from the gateway domain */ @@ -12,7 +12,7 @@ import react from "@vitejs/plugin-react"; // CloudBase sandbox preview setup: // - base "./" for static hosting deployment (relative asset paths) -// - dev server is launched with --base=/preview/ CLI flag which overrides this +// - dev server is launched with --base=/ (沙箱业务镜像 vite-dev-manager) which overrides this for preview // - server.host "0.0.0.0" lets the CloudBase gateway proxy reach the dev server // - server.allowedHosts true allows requests from the gateway domain export default defineConfig({ @@ -53,6 +53,11 @@ IMPORTANT: 页面需要做好 error 处理,显示出具体的错误堆栈信 - @cloudbase/js-sdk(云开发前端 SDK) + +项目代码在 **远程沙箱** 的 \`/home/user\`(或 prepare 返回的 workspace),不在本机 \`/tmp\` / \`openvibecoding-agent\` 目录。 +用户问 pwd、列文件、统计文件数:必须用 **Bash/Read/Glob** 在远程执行后作答,禁止根据 SDK 本机会话目录猜测。 + + 1. 仅使用以上技术栈,除非用户明确要求,不要引入新框架或库。 2. 新组件放在 src/components/,新页面放在 src/pages/ 并在 src/App.tsx 注册路由。 diff --git a/packages/server/src/agent/event-buffer.ts b/packages/server/src/agent/event-buffer.ts index a38bec7..ca9ad9d 100644 --- a/packages/server/src/agent/event-buffer.ts +++ b/packages/server/src/agent/event-buffer.ts @@ -12,6 +12,7 @@ const MILESTONE_SESSION_UPDATES = new Set([ 'tool_confirm', 'artifact', 'agent_phase', + 'log', ]) // ─── EventBuffer ─────────────────────────────────────────────────────── diff --git a/packages/server/src/agent/runtime/base-runtime.ts b/packages/server/src/agent/runtime/base-runtime.ts index f694a42..b922d1e 100644 --- a/packages/server/src/agent/runtime/base-runtime.ts +++ b/packages/server/src/agent/runtime/base-runtime.ts @@ -2,8 +2,8 @@ * BaseAgentRuntime * * 抽象基类:提供所有 runtime 共享的基础设施: - * - 沙箱生命周期(SCF 创建 + 健康检查 + workspace init) - * - MCP Server(sandbox-mcp-proxy) + * - Stateful sandbox lifecycle (provider acquire + workspace init) + * - MCP client (stateful-mcp-client) * - System Prompt(buildAppendPrompt + getCodingSystemPrompt) * - PublishableKey 获取 * @@ -16,16 +16,22 @@ */ import type { AgentCallback, AgentCallbackMessage, AgentOptions } from '@coder/shared' +import { sandboxLogMessageForTool } from '@coder/shared' +import { createTaskLogger } from '../../lib/task-logger.js' import type { ChatStreamResult, IAgentRuntime } from './types.js' import type { ModelInfo } from '../cloudbase-agent.service.js' -import { - scfSandboxManager, - type SandboxInstance, - type SandboxProgressCallback, -} from '../../sandbox/scf-sandbox-manager.js' -import { createSandboxMcpClient, type SandboxMcpDeps } from '../../sandbox/sandbox-mcp-proxy.js' +import { buildStatefulAcquireContext } from '../../sandbox/acquire-context.js' +import { formatAgsManagerError, formatAgsUserFacingError } from '../../sandbox/ags-error.js' +import { getSandboxProvider } from '../../sandbox/index.js' +import type { + SandboxInstance, + SandboxProgressCallback, + McpClientBundle, + ToolOverrideConfig, +} from '../../sandbox/provider/types.js' import { getDb } from '../../db/index.js' -import { resolveSandboxConfig, backfillSandboxConfig } from '../../lib/sandbox-config.js' +import type { Task } from '../../db/types.js' +import { STATEFUL_WORKSPACE_ROOT, resolveSandboxConfig, backfillSandboxConfig } from '../../lib/sandbox-config.js' import { getCodingSystemPrompt } from '../coding-mode.js' import { decrypt } from '../../lib/crypto.js' import { encryptJWE } from '../../lib/session.js' @@ -78,73 +84,41 @@ export async function waitForSandboxHealth( } /** - * 初始化沙箱工作空间:POST /api/session/init 注入凭证和环境变量 - * 然后 poll /api/scope/info 获取工作目录 + * @deprecated Use SandboxProvider.prepare() (沙箱业务镜像 POST /api/workspace/init). + * Kept for exports/tests; stateful path returns 沙箱业务镜像 workspace root immediately. */ export async function initSandboxWorkspace( sandbox: SandboxInstance, secret: { envId: string; secretId: string; secretKey: string; token?: string }, - conversationId: string, + _conversationId: string, preferredCwd?: string, onProgress?: SandboxProgressCallback, ): Promise<{ workspace: string; vitePort?: number }> { - const fallbackWorkspace = preferredCwd || `/tmp/workspace/${secret.envId}/${conversationId}` - + const fallbackWorkspace = preferredCwd || STATEFUL_WORKSPACE_ROOT onProgress?.({ phase: 'init_mcp', message: '初始化工作空间...\n' }) - // Fire session/init in background — injects credentials into the sandbox session. - sandbox - .request('/api/session/init', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - env: { - CLOUDBASE_ENV_ID: secret.envId, - TENCENTCLOUD_SECRETID: secret.secretId, - TENCENTCLOUD_SECRETKEY: secret.secretKey, - ...(secret.token ? { TENCENTCLOUD_SESSIONTOKEN: secret.token } : {}), + try { + const provider = getSandboxProvider() + const prepared = await provider.prepare( + sandbox, + { + credentials: { + envId: secret.envId, + secretId: secret.secretId, + secretKey: secret.secretKey, + sessionToken: secret.token, }, - }), - signal: AbortSignal.timeout(300_000), - }) - .then((res) => { - if (!res.ok) { - console.warn('[BaseRuntime] session/init returned status:', res.status) - } - }) - .catch((e) => { - console.warn('[BaseRuntime] session/init background error:', (e as Error).message) - }) - - // Poll /api/scope/info to get the workspace path - const maxWaitMs = 120_000 - const pollInterval = 2000 - const startTime = Date.now() - - while (Date.now() - startTime < maxWaitMs) { - try { - const res = await sandbox.request('/api/scope/info', { - signal: AbortSignal.timeout(10_000), - }) - if (res.ok) { - const data = (await res.json()) as { - success?: boolean - workspace?: string - vitePort?: number | null - } - if (data.success && data.workspace) { - onProgress?.({ phase: 'ready', message: '沙箱已就绪\n' }) - return { workspace: data.workspace, vitePort: data.vitePort ?? undefined } - } - } - } catch { - // scope/info not available yet - } - await new Promise((r) => setTimeout(r, pollInterval)) + workspaceHint: fallbackWorkspace, + backendOptions: { backend: 'stateful' }, + }, + onProgress, + ) + onProgress?.({ phase: 'ready', message: '沙箱已就绪\n' }) + return { workspace: prepared.workspace || STATEFUL_WORKSPACE_ROOT } + } catch (e) { + console.warn('[BaseRuntime] initSandboxWorkspace via workspace/init failed:', (e as Error).message) + return { workspace: fallbackWorkspace } } - - console.warn(`[BaseRuntime] initSandboxWorkspace timeout after ${maxWaitMs / 1000}s`) - return { workspace: fallbackWorkspace } } // ─── System Prompt ───────────────────────────────────────────────────────── @@ -183,6 +157,44 @@ export async function getPublishableKey(envId: string): Promise { } } +export interface BuildAppendPromptOptions { + /** CodeBuddy SDK session dir on the OpenVibeCoding 服务端主机 (not the user workspace). */ + localHostCwd?: string + /** Bash/Read/Write/Glob/Grep are routed to the remote 沙箱业务镜像 sandbox. */ + remoteToolsActive?: boolean +} + +function buildRemoteWorkspaceSection( + sandboxCwd: string, + conversationId: string | undefined, + options?: BuildAppendPromptOptions, +): string { + const localHostCwd = options?.localHostCwd?.trim() + const remoteActive = options?.remoteToolsActive === true + + if (remoteActive) { + const localLine = localHostCwd + ? `- **本机 SDK 会话目录** \`${localHostCwd}\`:仅用于 CodeBuddy 落盘 JSONL,**不是**用户工作区。禁止把该路径、\`/tmp\`、\`/var/folders\`、\`openvibecoding-agent\` 说成「当前工作目录」。` + : `- **本机 SDK 会话目录**:仅用于 CodeBuddy 落盘,**不是**用户工作区。禁止把 \`/tmp\`、\`/var/folders\`、\`openvibecoding-agent\` 说成「当前工作目录」。` + return ` + +你已连接 **CloudBase 远程沙箱**(Stateful 沙箱业务镜像)。用户项目只存在于沙箱 VM 内。 + +- **远程工作区根目录**:\`${sandboxCwd}\` — 所有 Bash / Read / Write / Glob / Grep 在该 VM 上执行。 +${localLine} +- 用户问 **pwd、当前目录、有哪些文件、统计文件数、目录结构、du/wc/find** 等:必须先调用 **Bash 或 Read/Glob** 在远程执行(例如 \`cd ${sandboxCwd} && pwd\`、\`cd ${sandboxCwd} && find . -type f | wc -l\`),**仅根据工具输出**回答。 +- **禁止**根据 SDK \`cwd\`、进程环境或未调用工具时的猜测回答工作区路径。 +- 若工具调用失败:说明沙箱/工具异常并请用户重试,**不要**编造本机临时目录。 +` + } + + return ` + +远程沙箱尚未连接或工具 override 未就绪。不要声称自己已在 \`/home/user\` 或本机临时目录中工作。 +若用户询问 pwd/文件列表:说明需等待沙箱就绪后再用 Bash 查询,勿用本机路径作答。 +` +} + /** * 构建通用 system prompt(任务分类 + CloudBase 指引 + 沙箱上下文) */ @@ -192,6 +204,7 @@ export function buildAppendPrompt( envId?: string, sandboxMode?: 'shared' | 'isolated', isCodingMode?: boolean, + promptOptions?: BuildAppendPromptOptions, ): string { const roleLine = isCodingMode ? '你是一个通用 AI 编程助手,同时具备腾讯云开发(CloudBase)能力。' @@ -214,6 +227,9 @@ export function buildAppendPrompt( 3) **自动化/定时类**("每天…"、"每周…"、"定期…") → 使用 cronTask 工具管理定时任务(见下面 cron-task 章节)。 +4) **工作区探查类**(pwd、当前目录、列文件、统计文件数、目录树、du/wc/find/ls) + → 必须使用 Bash / Read / Glob 在**远程沙箱**执行并据实回答;**禁止**用本机 SDK 临时目录或未调工具时的猜测作答。 + **不确定时优先问用户**:"你希望我直接写文案给你,还是做一个可访问的网页?",不要擅自升级为 2)。 ` @@ -252,11 +268,18 @@ Cron 表达式格式:分 时 日 月 周,例如 "0 20 * * *" 表示每天 20 ` if (sandboxCwd) { - const homeDir = sandboxMode === 'isolated' ? sandboxCwd : sandboxCwd.substring(0, sandboxCwd.lastIndexOf('/')) + const homeDir = + sandboxCwd === STATEFUL_WORKSPACE_ROOT || sandboxCwd.startsWith('/home/user') + ? sandboxCwd + : sandboxMode === 'isolated' + ? sandboxCwd + : sandboxCwd.substring(0, sandboxCwd.lastIndexOf('/')) const sandboxPreamble = isCodingMode ? '' - : '(以下仅在你已判定任务属于"编程/工程类"、决定动手写文件或执行命令时适用;对话/创作类任务请忽略本节。)\n' + : '(以下仅在你已判定任务属于"编程/工程类"或"工作区探查类"、决定动手写文件或执行命令时适用;纯对话/创作类任务请忽略本节。)\n' + const remoteSection = buildRemoteWorkspaceSection(sandboxCwd, conversationId, promptOptions) return `${base} +${remoteSection} ${sandboxPreamble}工具默认在 Home: ${homeDir} 下执行 @@ -287,7 +310,7 @@ export interface RuntimeContext { mode: 'default' | 'coding' isCodingMode: boolean - /** SCF 沙箱实例(null 表示沙箱不可用或禁用) */ + /** Stateful sandbox instance (null when sandbox disabled or unavailable) */ sandbox: SandboxInstance | null /** 沙箱内工作目录路径 */ sandboxCwd: string | null @@ -296,8 +319,8 @@ export interface RuntimeContext { /** sandbox session id */ sandboxSessionId: string - /** MCP client (sandbox-mcp-proxy),用于 CloudBase 工具调用 */ - mcpClient: Awaited> | null + /** MCP client (stateful in-process server) for CloudBase tools */ + mcpClient: McpClientBundle | null /** 构建好的 system prompt(含沙箱上下文 + coding mode) */ systemPrompt: string @@ -319,7 +342,7 @@ export abstract class BaseAgentRuntime implements IAgentRuntime { // ─── 公共设施方法(子类可在 runAgent 中调用) ──────────────────── /** - * 初始化完整的沙箱环境:创建 SCF → 健康检查 → workspace init → MCP client。 + * Initialize sandbox: acquire stateful instance → workspace init → MCP client. * * @returns RuntimeContext 中与沙箱相关的字段 */ @@ -337,16 +360,13 @@ export abstract class BaseAgentRuntime implements IAgentRuntime { sandboxMode: 'shared' | 'isolated' sandboxSessionId: string toolOverrideConfig: { url: string; headers: Record } | null - mcpClient: Awaited> | null + mcpClient: McpClientBundle | null /** Short-lived JWE session cookie for authenticating localhost requests (e.g. /cloudbase-mcp) */ sessionJwe: string | null }> { const { conversationId, envId, userId, userCredentials, isCodingMode, callback, model } = options - const sandboxEnabled = !!( - process.env.TCB_ENV_ID && - (process.env.SANDBOX_IMAGE_URI || process.env.SCF_SANDBOX_IMAGE_URI) - ) + const sandboxEnabled = !!(process.env.TCB_ENV_ID && process.env.TCB_API_KEY) if (!sandboxEnabled || !envId) { return { sandbox: null, @@ -361,22 +381,19 @@ export abstract class BaseAgentRuntime implements IAgentRuntime { // Read sandbox config from task record let sandboxConfig = resolveSandboxConfig({ envId, taskId: conversationId }) + let taskRecord: Task | null = null try { - const taskRecord = await getDb().tasks.findById(conversationId) + taskRecord = await getDb().tasks.findById(conversationId) await backfillSandboxConfig( conversationId, - { - sandboxMode: taskRecord?.sandboxMode, - sandboxSessionId: taskRecord?.sandboxSessionId, - sandboxCwd: taskRecord?.sandboxCwd, - }, + { sandboxMode: taskRecord?.sandboxMode, sandboxCwd: taskRecord?.sandboxCwd }, envId, getDb(), ) + taskRecord = await getDb().tasks.findById(conversationId) sandboxConfig = resolveSandboxConfig({ - sandboxMode: taskRecord?.sandboxMode, - sandboxSessionId: taskRecord?.sandboxSessionId, sandboxCwd: taskRecord?.sandboxCwd, + sandboxMode: taskRecord?.sandboxMode, envId, taskId: conversationId, }) @@ -384,37 +401,49 @@ export abstract class BaseAgentRuntime implements IAgentRuntime { // Non-critical } - const { sandboxMode, sandboxSessionId } = sandboxConfig - - // Progress bridge + const taskLogger = createTaskLogger(conversationId) + if (callback) { + taskLogger.registerACPNotifier((update) => { + if (update.sessionUpdate !== 'log') return + callback({ type: 'log', content: update.message, logLevel: update.level }) + }) + } + let lastSandboxProgressTool: string | undefined + let lastSandboxPhaseEmitted: string | undefined const progressBridge: SandboxProgressCallback = ({ phase }) => { + const toolName = `sandbox:${phase}` + if (lastSandboxPhaseEmitted === toolName) return + lastSandboxPhaseEmitted = toolName if (callback) { - callback({ type: 'agent_phase', phase: 'preparing', phaseToolName: `sandbox:${phase}` }) + callback({ type: 'agent_phase', phase: 'preparing', phaseToolName: toolName }) + } + const message = sandboxLogMessageForTool(toolName, { previousPrepareTool: lastSandboxProgressTool }) + if (message) void taskLogger.info(message) + if (toolName !== 'sandbox:ready' && toolName !== 'sandbox:error') { + lastSandboxProgressTool = toolName } } + const provider = getSandboxProvider() let sandboxInstance: SandboxInstance | null = null - let toolOverrideConfig: { url: string; headers: Record } | null = null - let mcpClient: Awaited> | null = null + let toolOverrideConfig: ToolOverrideConfig | null = null + let mcpClient: McpClientBundle | null = null let detectedCwd: string | null = null let capturedSessionJwe: string | null = null try { - sandboxInstance = await scfSandboxManager.getOrCreate( - conversationId, - envId, - { - mode: 'shared', - workspaceIsolation: sandboxMode, - sandboxSessionId, - isCodingMode, - }, + sandboxInstance = await provider.acquire( + buildStatefulAcquireContext({ + envId, + taskId: conversationId, + userId, + sandboxMode: sandboxConfig.sandboxMode, + sandboxId: taskRecord?.sandboxId, + }), progressBridge, ) - toolOverrideConfig = await sandboxInstance.getToolOverrideConfig() - - // Inject hosting presign config for ImageGen, and capture sessionJwe for /cloudbase-mcp auth + let hosting: ToolOverrideConfig['hosting'] | undefined try { const user = await getDb().users.findById(userId) if (user) { @@ -429,56 +458,59 @@ export abstract class BaseAgentRuntime implements IAgentRuntime { name: user.name || undefined, }, } - const sessionJwe = await encryptJWE(session, '2h') - capturedSessionJwe = sessionJwe + capturedSessionJwe = await encryptJWE(session, '2h') const serverPort = Number(process.env.PORT) || 3001 - ;(toolOverrideConfig as any).hosting = { + hosting = { presignUrl: `http://localhost:${serverPort}/api/storage/presign?bucketType=static`, - sessionCookie: sessionJwe, + sessionCookie: capturedSessionJwe, sessionId: conversationId, } } } catch { - // hosting presign failure doesn't affect main flow + // optional } - // Health check - const sandboxReady = await waitForSandboxHealth(sandboxInstance, progressBridge) - if (!sandboxReady) { - if (callback) { - callback({ type: 'text', content: '沙箱启动超时,将使用受限模式继续对话。\n\n' }) - } - sandboxInstance = null - } else { - // Init workspace - const initResult = await initSandboxWorkspace( + toolOverrideConfig = await provider.getToolOverrideConfig(sandboxInstance, hosting) + + let prepared: Awaited> | null = null + try { + prepared = await provider.prepare( sandboxInstance, { - envId, - secretId: userCredentials?.secretId || '', - secretKey: userCredentials?.secretKey || '', - token: userCredentials?.sessionToken, + credentials: { + envId, + secretId: userCredentials?.secretId || '', + secretKey: userCredentials?.secretKey || '', + sessionToken: userCredentials?.sessionToken, + }, + workspaceHint: sandboxConfig.sandboxCwd || STATEFUL_WORKSPACE_ROOT, + codingMode: isCodingMode, + backendOptions: { backend: 'stateful' }, }, - conversationId, - sandboxConfig.sandboxCwd || undefined, progressBridge, ) - if (initResult.workspace) { - detectedCwd = initResult.workspace + } catch { + if (callback) { + callback({ type: 'text', content: '沙箱启动超时,将使用受限模式继续对话。\n\n' }) } + sandboxInstance = null + } + + if (sandboxInstance && prepared) { + if (prepared.workspace) detectedCwd = prepared.workspace - // Create MCP client - mcpClient = await createSandboxMcpClient({ + mcpClient = await provider.createMcpClient({ sandbox: sandboxInstance, - userId, - envId, + getCredentials: async () => ({ + cloudbaseEnvId: envId, + secretId: userCredentials?.secretId || '', + secretKey: userCredentials?.secretKey || '', + sessionToken: userCredentials?.sessionToken, + }), workspaceFolderPaths: detectedCwd || sandboxConfig.sandboxCwd, log: (msg) => console.log(msg), onArtifact: (artifact) => { - if (callback) { - callback({ type: 'artifact', artifact }) - } - // 持久化部署记录(所有 runtime 共用) + if (callback) callback({ type: 'artifact', artifact }) persistDeploymentFromArtifact(conversationId, artifact).catch((err) => { console.error('[BaseRuntime] Failed to persist deployment:', err) }) @@ -492,21 +524,25 @@ export abstract class BaseAgentRuntime implements IAgentRuntime { currentModel: model, }) - // Persist sandboxId to task record try { await getDb().tasks.update(conversationId, { - sandboxId: sandboxInstance.functionName, + sandboxId: sandboxInstance.id, + sandboxCwd: detectedCwd || sandboxConfig.sandboxCwd, + sandboxMode: sandboxConfig.sandboxMode, + updatedAt: Date.now(), }) } catch { - // Non-critical + // non-critical } } } catch (err) { - console.error('[BaseRuntime] Sandbox creation failed:', (err as Error).message) + const detail = formatAgsManagerError(err, 'sandbox.acquire') + const userDetail = formatAgsUserFacingError(err) + console.error('[BaseRuntime] Sandbox creation failed:', detail) if (callback) { callback({ type: 'text', - content: `【沙箱环境创建失败】${(err as Error).message}。将使用受限模式继续对话。\n\n`, + content: `【沙箱环境创建失败】\n${userDetail}\n\n将使用受限模式继续对话。\n\n`, }) } } @@ -514,8 +550,8 @@ export abstract class BaseAgentRuntime implements IAgentRuntime { return { sandbox: sandboxInstance, sandboxCwd: detectedCwd, - sandboxMode, - sandboxSessionId, + sandboxMode: sandboxConfig.sandboxMode, + sandboxSessionId: envId, toolOverrideConfig, mcpClient, sessionJwe: capturedSessionJwe, @@ -531,18 +567,25 @@ export abstract class BaseAgentRuntime implements IAgentRuntime { sandboxCwd: string | null sandboxMode: 'shared' | 'isolated' conversationId: string + remoteToolsActive?: boolean + localHostCwd?: string }): Promise<{ systemPrompt: string; publishableKey: string }> { - const { envId, isCodingMode, sandboxCwd, sandboxMode, conversationId } = options + const { envId, isCodingMode, sandboxCwd, sandboxMode, conversationId, remoteToolsActive, localHostCwd } = options const publishableKey = await getPublishableKey(envId) + const appendOpts: BuildAppendPromptOptions = { + remoteToolsActive, + localHostCwd, + } + const cwdForPrompt = sandboxCwd || undefined let systemPrompt: string if (isCodingMode) { systemPrompt = getCodingSystemPrompt(envId, publishableKey) + '\n\n' + - buildAppendPrompt(sandboxCwd || undefined, conversationId, envId, sandboxMode, true) + buildAppendPrompt(cwdForPrompt, conversationId, envId, sandboxMode, true, appendOpts) } else { - systemPrompt = buildAppendPrompt(sandboxCwd || undefined, conversationId, envId, sandboxMode, false) + systemPrompt = buildAppendPrompt(cwdForPrompt, conversationId, envId, sandboxMode, false, appendOpts) } return { systemPrompt, publishableKey } diff --git a/packages/server/src/agent/runtime/opencode-acp-runtime.ts b/packages/server/src/agent/runtime/opencode-acp-runtime.ts index 512044d..e6d03fa 100644 --- a/packages/server/src/agent/runtime/opencode-acp-runtime.ts +++ b/packages/server/src/agent/runtime/opencode-acp-runtime.ts @@ -10,7 +10,7 @@ * 同名 custom tool 覆盖 opencode builtin — read/write/bash/edit/grep/glob。 * * 每次 chatStream: - * 1. BaseAgentRuntime.setupSandbox() 创建/获取 SCF 沙箱(共享实例) + * 1. BaseAgentRuntime.setupSandbox() acquires shared stateful sandbox * 2. spawn opencode acp,通过 child env 注入: * OPENCODE_CONFIG_DIR=/.opencode (隔离用户全局配置) * SANDBOX_MODE=1 @@ -45,8 +45,11 @@ import { registerPending, resolvePending, rejectPendingForConversation } from '. import { resolvePendingQuestion, rejectPendingQuestionsForConversation } from './pending-question-registry.js' import { OpencodeMessageBuilder, findLastRecordIds, buildHistoryContextPrompt } from './opencode-message-builder.js' import { BaseAgentRuntime } from './base-runtime.js' -import type { SandboxInstance } from '../../sandbox/scf-sandbox-manager.js' +import type { SandboxInstance } from '../../sandbox/provider/types.js' import { archiveToGit } from '../../sandbox/git-archive.js' +import { appendAllowedTaskLog } from '../../lib/append-task-log.js' +import { TASK_LOG } from '@coder/shared' +import { resolveAgentHostCwd } from '../../lib/sandbox-config.js' import os from 'node:os' import path from 'node:path' import fs from 'node:fs' @@ -313,7 +316,6 @@ export class OpencodeAcpRuntime extends BaseAgentRuntime { let sandboxResult: Awaited> | null = null if (envId) { - await emit({ type: 'agent_phase', phase: 'preparing' }) sandboxResult = await this.setupSandbox({ conversationId, envId, @@ -335,12 +337,15 @@ export class OpencodeAcpRuntime extends BaseAgentRuntime { // 构建系统提示 let systemPrompt = '' if (envId) { + const remoteCwd = sandboxResult?.sandboxCwd || null const promptResult = await this.buildSystemPrompt({ envId, isCodingMode, - sandboxCwd: sandboxResult?.sandboxCwd || null, + sandboxCwd: remoteCwd, sandboxMode: sandboxResult?.sandboxMode || 'shared', conversationId, + remoteToolsActive: !!sandboxResult?.toolOverrideConfig, + localHostCwd: remoteCwd ? resolveAgentHostCwd(remoteCwd, conversationId) : undefined, }) systemPrompt = promptResult.systemPrompt } @@ -531,7 +536,12 @@ export class OpencodeAcpRuntime extends BaseAgentRuntime { content: JSON.stringify({ stopReason: promptRes.stopReason, usage: (promptRes as { _meta?: { usage?: unknown } })._meta?.usage ?? null, - sandbox: sandbox ? { baseUrl: sandbox.baseUrl, conversationId: sandbox.conversationId } : null, + sandbox: sandbox + ? { + baseUrl: sandbox.baseUrl, + conversationId: String((sandbox.meta as { conversationId?: string }).conversationId || conversationId), + } + : null, workingDir: sessionWorkingDir, }), }) @@ -581,8 +591,10 @@ export class OpencodeAcpRuntime extends BaseAgentRuntime { } // Archive to git(含 error/cancel 场景,保留最终工作状态) if (sandbox) { - archiveToGit(sandbox, conversationId, prompt).catch((err) => { - console.error('[OpencodeAcpRuntime] archiveToGit failed:', err) + void archiveToGit(sandbox, conversationId, prompt).then((result) => { + if (result === 'ok') void appendAllowedTaskLog(conversationId, 'info', TASK_LOG.PLATFORM_ARCHIVE_PUSH_OK) + else if (result === 'fail') + void appendAllowedTaskLog(conversationId, 'error', TASK_LOG.PLATFORM_ARCHIVE_PUSH_FAILED) }) } // Close sandbox MCP client(同 CodeBuddy runtime 对齐) diff --git a/packages/server/src/db/cloudbase/repositories.ts b/packages/server/src/db/cloudbase/repositories.ts index 5e2ea5f..0fad3b4 100644 --- a/packages/server/src/db/cloudbase/repositories.ts +++ b/packages/server/src/db/cloudbase/repositories.ts @@ -215,7 +215,7 @@ function withTaskDefaults(task: Record): Task { status: doc.status || 'pending', selectedAgent: doc.selectedAgent ?? 'codebuddy', selectedRuntime: doc.selectedRuntime ?? null, - sandboxMode: doc.sandboxMode || 'isolated', + sandboxMode: doc.sandboxMode || 'shared', installDependencies: doc.installDependencies ?? false, maxDuration: doc.maxDuration ?? 300, keepAlive: doc.keepAlive ?? false, @@ -750,9 +750,23 @@ class CloudBaseUserResourceRepository implements UserResourceRepository { const collection = await getCollection('user_resources') const ts = now() const doc: UserResource = { - ...resource, + id: resource.id, + userId: resource.userId, + status: resource.status, scope: resource.scope || 'user', taskId: resource.taskId ?? null, + envId: resource.envId ?? null, + statefulToolId: resource.statefulToolId ?? null, + envAlias: resource.envAlias ?? null, + envRegion: resource.envRegion ?? null, + cosTagValue: resource.cosTagValue ?? null, + policyHash: resource.policyHash ?? null, + camUsername: resource.camUsername ?? null, + camSecretId: resource.camSecretId ?? null, + camSecretKey: resource.camSecretKey ?? null, + policyId: resource.policyId ?? null, + failStep: resource.failStep ?? null, + failReason: resource.failReason ?? null, createdAt: resource.createdAt ?? ts, updatedAt: resource.updatedAt ?? ts, } diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 1bd4972..240954e 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -218,6 +218,7 @@ export const userResources = sqliteTable('user_resources', { taskId: text('task_id').references(() => tasks.id, { onDelete: 'set null' }), status: text('status').notNull().default('pending'), envId: text('env_id'), + statefulToolId: text('stateful_tool_id'), envAlias: text('env_alias'), envRegion: text('env_region'), cosTagValue: text('cos_tag_value'), diff --git a/packages/server/src/db/types.ts b/packages/server/src/db/types.ts index 810a075..d0be6c6 100644 --- a/packages/server/src/db/types.ts +++ b/packages/server/src/db/types.ts @@ -143,6 +143,7 @@ export interface UserResource { taskId: string | null // set when scope='task' status: string envId: string | null + statefulToolId: string | null envAlias: string | null envRegion: string | null cosTagValue: string | null @@ -292,12 +293,28 @@ export type NewKey = Omit & { updatedAt?: number } -export type NewUserResource = Omit & { - scope?: string // defaults to 'user' - taskId?: string | null - createdAt?: number - updatedAt?: number -} +type UserResourceNullableFields = + | 'envId' + | 'statefulToolId' + | 'envAlias' + | 'envRegion' + | 'cosTagValue' + | 'policyHash' + | 'camUsername' + | 'camSecretId' + | 'camSecretKey' + | 'policyId' + | 'failStep' + | 'failReason' + | 'taskId' + +export type NewUserResource = Omit & + Partial> & { + scope?: string // defaults to 'user' + taskId?: string | null + createdAt?: number + updatedAt?: number + } export type NewSetting = Omit & { createdAt?: number diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 2d2bdce..a6e05c2 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,4 +1,6 @@ import { serve } from '@hono/node-server' +import type { Server } from 'node:http' +import { attachPreviewWebSocketProxy } from './sandbox/preview-ws-proxy.js' import { Hono } from 'hono' import { cors } from 'hono/cors' import { serveStatic } from '@hono/node-server/serve-static' @@ -156,7 +158,7 @@ if (!process.env.ASK_USER_BASE_URL) { process.env.ASK_USER_BASE_URL = `http://127.0.0.1:${PORT}` } -serve({ fetch: app.fetch, port: PORT }, () => { +const server = serve({ fetch: app.fetch, port: PORT }, () => { console.log(`Server running on http://localhost:${PORT}`) if (serveStaticFiles) { console.log(`Open http://localhost:${PORT} in your browser`) @@ -179,6 +181,8 @@ serve({ fetch: app.fetch, port: PORT }, () => { // Backfill API keys for existing users backfillApiKeys() -}) +}) as Server + +attachPreviewWebSocketProxy(server) export default app diff --git a/packages/server/src/lib/__tests__/cloudbase-mcp-inject-credentials.test.ts b/packages/server/src/lib/__tests__/cloudbase-mcp-inject-credentials.test.ts index 2e7f7dd..d2f8c5a 100644 --- a/packages/server/src/lib/__tests__/cloudbase-mcp-inject-credentials.test.ts +++ b/packages/server/src/lib/__tests__/cloudbase-mcp-inject-credentials.test.ts @@ -14,14 +14,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // ── Mock 依赖(vi.hoisted 确保变量在 vi.mock factory 之前已初始化) ───── -const { findByUserIdMock, issueTempCredentialsMock } = vi.hoisted(() => ({ +const { findByUserIdMock, findByTaskIdMock, issueTempCredentialsMock } = vi.hoisted(() => ({ findByUserIdMock: vi.fn(), + findByTaskIdMock: vi.fn(), issueTempCredentialsMock: vi.fn(), })) vi.mock('../../db/index.js', () => ({ getDb: () => ({ - userResources: { findByUserId: findByUserIdMock }, + userResources: { + findByUserId: findByUserIdMock, + findByTaskId: findByTaskIdMock, + }, }), })) @@ -33,6 +37,8 @@ import { createInjectCredentials } from '../cloudbase-mcp.js' beforeEach(() => { findByUserIdMock.mockReset() + findByTaskIdMock.mockReset() + findByTaskIdMock.mockResolvedValue(null) issueTempCredentialsMock.mockReset() }) @@ -44,9 +50,27 @@ function makeOkResponse(body: unknown = { success: true }, status = 200): Respon return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) } +/** Matches createInjectCredentials envId/userId guards on user_resources rows. */ +function mockUserResource( + overrides: { + userId?: string + envId?: string + camSecretId?: string | null + camSecretKey?: string | null + } = {}, +) { + return { + userId: 'u1', + envId: 'env-1', + camSecretId: null, + camSecretKey: null, + ...overrides, + } +} + describe('createInjectCredentials', () => { it('uses permanent credentials when camSecretId/camSecretKey present', async () => { - findByUserIdMock.mockResolvedValue({ camSecretId: 'AKID-PERM', camSecretKey: 'KEY-PERM' }) + findByUserIdMock.mockResolvedValue(mockUserResource({ camSecretId: 'AKID-PERM', camSecretKey: 'KEY-PERM' })) const fetcher = makeFetch(async () => makeOkResponse()) const fn = createInjectCredentials({ @@ -64,18 +88,18 @@ describe('createInjectCredentials', () => { // 沙箱请求体应含永久 secretId / secretKey expect(fetcher).toHaveBeenCalledTimes(1) const [path, init] = fetcher.mock.calls[0] - expect(path).toBe('/api/session/env') + expect(path).toBe('/api/workspace/env') expect(init?.method).toBe('PUT') const body = JSON.parse(init!.body as string) expect(body.CLOUDBASE_ENV_ID).toBe('env-1') expect(body.TENCENTCLOUD_SECRETID).toBe('AKID-PERM') expect(body.TENCENTCLOUD_SECRETKEY).toBe('KEY-PERM') expect(body.TENCENTCLOUD_SESSIONTOKEN).toBe('') // 永久密钥无 token - expect(body.conversationId).toBe('c1') + expect(body.conversationId).toBeUndefined() }) it('falls back to issueTempCredentials when no permanent credentials', async () => { - findByUserIdMock.mockResolvedValue({ camSecretId: null, camSecretKey: null }) + findByUserIdMock.mockResolvedValue(mockUserResource()) issueTempCredentialsMock.mockResolvedValue({ secretId: 'AKID-TEMP', secretKey: 'KEY-TEMP', @@ -115,7 +139,7 @@ describe('createInjectCredentials', () => { }) it('throws when sandbox returns success=false', async () => { - findByUserIdMock.mockResolvedValue({ camSecretId: 'AKID', camSecretKey: 'KEY' }) + findByUserIdMock.mockResolvedValue(mockUserResource({ camSecretId: 'AKID', camSecretKey: 'KEY' })) const fetcher = makeFetch(async () => makeOkResponse({ success: false, error: 'env not ready' })) const fn = createInjectCredentials({ @@ -129,7 +153,7 @@ describe('createInjectCredentials', () => { }) it('calls on401 callback when sandbox returns 401', async () => { - findByUserIdMock.mockResolvedValue({ camSecretId: 'AKID', camSecretKey: 'KEY' }) + findByUserIdMock.mockResolvedValue(mockUserResource({ camSecretId: 'AKID', camSecretKey: 'KEY' })) const fetcher = makeFetch(async () => makeOkResponse({}, 401)) const on401 = vi.fn((status: number) => { throw new Error(`AUTH_REQUIRED:${status}`) @@ -148,7 +172,7 @@ describe('createInjectCredentials', () => { }) it('includes WORKSPACE_FOLDER_PATHS only when provided', async () => { - findByUserIdMock.mockResolvedValue({ camSecretId: 'AKID', camSecretKey: 'KEY' }) + findByUserIdMock.mockResolvedValue(mockUserResource({ camSecretId: 'AKID', camSecretKey: 'KEY' })) const fetcher = makeFetch(async () => makeOkResponse()) // 1. 不传 workspaceFolderPaths diff --git a/packages/server/src/lib/append-task-log.ts b/packages/server/src/lib/append-task-log.ts new file mode 100644 index 0000000..06c6b64 --- /dev/null +++ b/packages/server/src/lib/append-task-log.ts @@ -0,0 +1,22 @@ +import type { LogEntry } from '@coder/shared' +import { isAllowedTaskLogMessage } from '@coder/shared' +import { createTaskLogger } from './task-logger.js' + +/** Append a whitelisted static line to task.logs (and SSE log stream when notifier registered). */ +export async function appendAllowedTaskLog(taskId: string, level: LogEntry['type'], message: string): Promise { + if (!isAllowedTaskLogMessage(message)) return + const logger = createTaskLogger(taskId) + switch (level) { + case 'success': + await logger.success(message) + break + case 'error': + await logger.error(message) + break + case 'command': + await logger.command(message) + break + default: + await logger.info(message) + } +} diff --git a/packages/server/src/lib/cloudbase-ags-api.ts b/packages/server/src/lib/cloudbase-ags-api.ts new file mode 100644 index 0000000..1714772 --- /dev/null +++ b/packages/server/src/lib/cloudbase-ags-api.ts @@ -0,0 +1,64 @@ +/** + * AGS control-plane calls via @cloudbase/manager-node. + * Use explicit …/lib/utils/index.js — Node ESM rejects directory imports (ERR_UNSUPPORTED_DIR_IMPORT). + */ + +export type AgsManagerCredentials = { + secretId: string + secretKey: string + token?: string + envId: string +} + +type CloudBaseCtor = new (config: { secretId: string; secretKey: string; token?: string; envId: string }) => { + context: unknown +} + +type CloudServiceCtor = new ( + context: unknown, + service: string, + version: string, +) => { + request: (action: string, param: Record) => Promise +} + +async function loadManagerConstructors(): Promise<{ + CloudBase: CloudBaseCtor + CloudService: CloudServiceCtor +}> { + const managerModule = await import('@cloudbase/manager-node') + const utilsModule = await import('@cloudbase/manager-node/lib/utils/index.js') + const CloudBase = (managerModule.default ?? managerModule) as CloudBaseCtor + const CloudService = utilsModule.CloudService as CloudServiceCtor + if (!CloudService) { + throw new Error('CloudService export missing from @cloudbase/manager-node/lib/utils/index.js') + } + return { CloudBase, CloudService } +} + +export async function callAgsManagerApi( + action: string, + param: Record, + creds: AgsManagerCredentials, +): Promise> { + const { CloudBase, CloudService } = await loadManagerConstructors() + const app = new CloudBase({ + secretId: creds.secretId, + secretKey: creds.secretKey, + token: creds.token, + envId: creds.envId, + }) + const agsService = new CloudService(app.context, 'ags', '2025-09-20') + return (await agsService.request(action, param)) as Record +} + +export function agsCredentialsFromProcessEnv(): AgsManagerCredentials { + const secretId = process.env.TCB_SECRET_ID || '' + const secretKey = process.env.TCB_SECRET_KEY || '' + const envId = process.env.TCB_ENV_ID || '' + const token = process.env.TCB_TOKEN || process.env.TENCENTCLOUD_SESSIONTOKEN || '' + if (!secretId || !secretKey || !envId) { + throw new Error('TCB_ENV_ID and TCB_SECRET_ID/KEY are required to manage sandbox tools') + } + return { secretId, secretKey, token, envId } +} diff --git a/packages/server/src/lib/cloudbase-mcp.ts b/packages/server/src/lib/cloudbase-mcp.ts index 41a5747..a01ea86 100644 --- a/packages/server/src/lib/cloudbase-mcp.ts +++ b/packages/server/src/lib/cloudbase-mcp.ts @@ -9,7 +9,7 @@ * * 调用方: * - routes/cloudbase-mcp.ts OpenCode HTTP runtime - * - sandbox/sandbox-mcp-proxy.ts CodeBuddy SDK runtime(InMemoryTransport server+client) + * - sandbox/stateful/stateful-mcp-client.ts CodeBuddy SDK runtime(InMemoryTransport) */ import { z } from 'zod' @@ -124,7 +124,7 @@ export interface DiscoveredTool { /** * 通过 mcporter list --schema 发现所有 cloudbase 工具。 * - * 双 runtime 共用此函数:sandbox-mcp-proxy 注入 SandboxInstance 风格 bash/read, + * 双 runtime 共用此函数:stateful-mcp-client 注入 SandboxInstance 风格 bash/read, * routes/cloudbase-mcp 注入直接 fetch 风格的 bash/read。 */ export async function discoverCloudbaseTools(deps: DiscoverToolsDeps): Promise { @@ -166,7 +166,7 @@ export interface CloudbaseMcpLogger { * * @param tag 日志前缀(默认 'cloudbase-mcp') * @param sink 行级 sink(接收已含前缀的整行字符串,默认 console) - * sandbox-mcp-proxy 可传 (line) => log(line + '\n') 兼容它的流式 logger + * stateful-mcp-client 可传 (line) => log(line + '\n') 兼容它的流式 logger */ export function createCloudbaseMcpLogger( tag = 'cloudbase-mcp', @@ -198,7 +198,7 @@ export interface CreateInjectCredentialsOptions { userId: string /** 当前会话 envId */ envId: string - /** 当前会话 conversationId(沙箱 /api/session/env 需要) */ + /** 当前会话 conversationId(task id; used for credential lookup only) */ conversationId: string /** * 沙箱请求函数。两条 runtime 各自传入: @@ -214,7 +214,7 @@ export interface CreateInjectCredentialsOptions { /** * 创建一个 injectCredentials 函数:通过 issueTempCredentials 拿凭证(永久密钥优先), - * 调沙箱 /api/session/env 写入。 + * 调 沙箱业务镜像 PUT /api/workspace/env 写入。 */ export function createInjectCredentials(opts: CreateInjectCredentialsOptions): InjectCredentialsFn { const { userId, envId, conversationId, sandboxFetch, workspaceFolderPaths, on401 } = opts @@ -245,11 +245,10 @@ export function createInjectCredentials(opts: CreateInjectCredentialsOptions): I } if (!creds) throw new Error('Failed to obtain user credentials for injection') - const res = await sandboxFetch('/api/session/env', { + const res = await sandboxFetch('/api/workspace/env', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - conversationId, CLOUDBASE_ENV_ID: envId, TENCENTCLOUD_SECRETID: creds.secretId, TENCENTCLOUD_SECRETKEY: creds.secretKey, diff --git a/packages/server/src/lib/mcp-middleware/__tests__/loader-artifacts.test.ts b/packages/server/src/lib/mcp-middleware/__tests__/loader-artifacts.test.ts new file mode 100644 index 0000000..28490ea --- /dev/null +++ b/packages/server/src/lib/mcp-middleware/__tests__/loader-artifacts.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { isBundledServerArtifact } from '../loader.js' + +describe('isBundledServerArtifact', () => { + it('flags tsup dist chunks', () => { + expect(isBundledServerArtifact('chunk-4EL5ZUGZ.js')).toBe(true) + expect(isBundledServerArtifact('ttyd-preview-JUB6GMYX.js')).toBe(true) + expect(isBundledServerArtifact('index.js')).toBe(true) + }) + + it('allows policy modules', () => { + expect(isBundledServerArtifact('auth.ts')).toBe(false) + expect(isBundledServerArtifact('cronTask.js')).toBe(false) + }) +}) diff --git a/packages/server/src/lib/mcp-middleware/loader.ts b/packages/server/src/lib/mcp-middleware/loader.ts index b7992aa..12c2fe8 100644 --- a/packages/server/src/lib/mcp-middleware/loader.ts +++ b/packages/server/src/lib/mcp-middleware/loader.ts @@ -47,6 +47,14 @@ export interface PolicyLoaderOptions { allowList?: string[] } +/** tsup bundles server into dist/*.js; avoid scanning chunk/hash artifacts as policies. */ +export function isBundledServerArtifact(fileName: string): boolean { + if (fileName === 'index.js') return true + if (fileName.startsWith('chunk-')) return true + if (/^[a-z0-9_.-]+-[A-Z0-9]{6,}\.(js|mjs)$/.test(fileName)) return true + return false +} + export function createPolicyLoader>( /** 扫描目录的绝对路径(一般是 path.dirname(fileURLToPath(import.meta.url))) */ dir: string, @@ -54,6 +62,8 @@ export function createPolicyLoader>( logTag: string = 'mcp-policies', /** 全局过滤配置 */ options: PolicyLoaderOptions = {}, + /** When set (recommended for bundled production), skip filesystem scan entirely. */ + staticPolicies?: Record>, ): PolicyLoader { const policyMap = new Map>() let loaded = false @@ -92,6 +102,14 @@ export function createPolicyLoader>( if (loaded) return loaded = true + if (staticPolicies) { + for (const [toolName, policy] of Object.entries(staticPolicies)) { + policyMap.set(toolName, policy) + console.log(`[${logTag}] loaded:`, toolName) + } + return + } + let entries: string[] try { entries = fs.readdirSync(dir) @@ -102,6 +120,7 @@ export function createPolicyLoader>( const candidates = entries.filter((name) => { if (name.startsWith('_')) return false + if (isBundledServerArtifact(name)) return false return name.endsWith('.ts') || name.endsWith('.js') || name.endsWith('.mjs') }) diff --git a/packages/server/src/lib/sandbox-config.ts b/packages/server/src/lib/sandbox-config.ts index ffe3ba9..9f68c0e 100644 --- a/packages/server/src/lib/sandbox-config.ts +++ b/packages/server/src/lib/sandbox-config.ts @@ -1,52 +1,114 @@ /** - * Sandbox config resolution helper. - * - * Centralises the logic for computing sandboxMode / sandboxSessionId / sandboxCwd - * so that task creation, agent start, preview-url, and scripts all follow the - * same rules. + * Sandbox config — 沙箱业务镜像 workspace root + instance isolation mode (shared | isolated). */ +import os from 'node:os' +import path from 'node:path' +import { getDb } from '../db/index.js' +import { getProvisionMode, type ProvisionMode } from './provision-config.js' + +/** 沙箱业务镜像 vibecoding preset workspace root (flat project tree). */ +export const STATEFUL_WORKSPACE_ROOT = '/home/user' + +export type SandboxInstanceMode = 'shared' | 'isolated' + export interface SandboxConfig { - sandboxMode: 'shared' | 'isolated' - sandboxSessionId: string + sandboxMode: SandboxInstanceMode sandboxCwd: string } interface ResolveParams { - sandboxMode?: string | null - sandboxSessionId?: string | null sandboxCwd?: string | null + sandboxMode?: string | null envId: string taskId: string } +export type SandboxInstanceModeSource = 'db' | 'env' | 'default' + +const VALID_MODES: SandboxInstanceMode[] = ['shared', 'isolated'] +const BUILTIN_DEFAULT: SandboxInstanceMode = 'shared' + +export function normalizeSandboxMode(mode: string | null | undefined): SandboxInstanceMode { + if (mode === 'isolated' || mode === 'shared') return mode + return BUILTIN_DEFAULT +} + +/** Legacy SCF per-task paths; migrate to stateful root. */ +export function isLegacyScfSandboxCwd(cwd: string | null | undefined): boolean { + if (!cwd) return false + return cwd.startsWith('/tmp/workspace/') +} + +export function normalizeSandboxCwd(cwd: string | null | undefined): string { + if (!cwd || isLegacyScfSandboxCwd(cwd)) return STATEFUL_WORKSPACE_ROOT + return cwd +} + /** - * Resolve a complete sandbox config from (possibly partial) task record fields. - * - * Rules: - * 1. sandboxMode = DB value || WORKSPACE_ISOLATION env || 'shared' - * 2. sandboxSessionId = DB value || (shared ? envId : taskId) - * 3. sandboxCwd = DB value || (shared ? /tmp/workspace/${envId}/${taskId} : /tmp/workspace/${taskId}) + * Host path for CodeBuddy SDK session JSONL (hash(cwd) under ~/.codebuddy/projects). + * 沙箱业务镜像 workspace is /home/user on the sandbox VM; the SDK must not use that path on macOS. + * Keep in sync with CloudbaseAgentService query({ cwd }). */ +export function resolveAgentHostCwd(workspaceCwd: string, conversationId: string): string { + if (workspaceCwd === STATEFUL_WORKSPACE_ROOT || workspaceCwd.startsWith('/home/user')) { + return path.join(os.tmpdir(), 'openvibecoding-agent', conversationId) + } + return workspaceCwd +} + export function resolveSandboxConfig(params: ResolveParams): SandboxConfig { - const { envId, taskId } = params + const sandboxCwd = normalizeSandboxCwd(params.sandboxCwd) + const sandboxMode = normalizeSandboxMode(params.sandboxMode) + return { sandboxMode, sandboxCwd } +} - const sandboxMode: 'shared' | 'isolated' = - (params.sandboxMode as 'shared' | 'isolated') || - (process.env.WORKSPACE_ISOLATION === 'isolated' ? 'isolated' : 'shared') +/** + * Default instance mode for new tasks (before per-task override). + * Priority: DB `sandbox_instance_mode` → env `WORKSPACE_ISOLATION` → provision-aware builtin. + */ +export async function resolveSandboxInstanceMode(): Promise<{ + value: SandboxInstanceMode + source: SandboxInstanceModeSource + envDefault: SandboxInstanceMode +}> { + const envIsolation = process.env.WORKSPACE_ISOLATION || '' + const envDefault = normalizeSandboxMode(envIsolation || BUILTIN_DEFAULT) - const sandboxSessionId: string = params.sandboxSessionId || (sandboxMode === 'shared' ? envId : taskId) + try { + const setting = await getDb().settings.findSystemSetting('sandbox_instance_mode') + if (setting?.value) { + return { value: normalizeSandboxMode(setting.value), source: 'db', envDefault } + } + } catch { + // DB unavailable + } - const sandboxCwd: string = - params.sandboxCwd || (sandboxMode === 'shared' ? `/tmp/workspace/${envId}/${taskId}` : `/tmp/workspace/${taskId}`) + if (envIsolation) { + return { value: envDefault, source: 'env', envDefault } + } - return { sandboxMode, sandboxSessionId, sandboxCwd } + const provisionMode = await getProvisionMode() + const value = defaultModeForProvision(provisionMode) + return { value, source: 'default', envDefault } +} + +function defaultModeForProvision(provisionMode: ProvisionMode): SandboxInstanceMode { + // task-level CloudBase env pairs naturally with per-task sandbox instances. + if (provisionMode === 'task') return 'isolated' + return BUILTIN_DEFAULT +} + +export function isValidSandboxInstanceMode(val: string): val is SandboxInstanceMode { + return VALID_MODES.includes(val as SandboxInstanceMode) +} + +/** Default sandbox instance mode when creating a task (honours body override). */ +export async function resolveSandboxModeForNewTask(bodyMode?: string | null): Promise { + if (bodyMode && isValidSandboxInstanceMode(bodyMode)) return bodyMode + return (await resolveSandboxInstanceMode()).value } -/** - * Check if a task record is missing any sandbox config fields and, if so, - * compute and persist the backfilled values. Returns true when an update was written. - */ export async function backfillSandboxConfig( taskId: string, existing: { @@ -57,21 +119,29 @@ export async function backfillSandboxConfig( envId: string, db: { tasks: { update: (id: string, data: Record) => Promise } }, ): Promise { - if (existing.sandboxMode && existing.sandboxSessionId && existing.sandboxCwd) { - return false - } + const normalizedCwd = normalizeSandboxCwd(existing.sandboxCwd) + const normalizedMode = existing.sandboxMode + ? normalizeSandboxMode(existing.sandboxMode) + : (await resolveSandboxInstanceMode()).value + + const needsUpdate = + !existing.sandboxCwd || + isLegacyScfSandboxCwd(existing.sandboxCwd) || + existing.sandboxCwd !== normalizedCwd || + !existing.sandboxMode || + normalizeSandboxMode(existing.sandboxMode) !== normalizedMode + + if (!needsUpdate) return false const config = resolveSandboxConfig({ - sandboxMode: existing.sandboxMode, - sandboxSessionId: existing.sandboxSessionId, - sandboxCwd: existing.sandboxCwd, + sandboxCwd: normalizedCwd, + sandboxMode: normalizedMode, envId, taskId, }) await db.tasks.update(taskId, { sandboxMode: config.sandboxMode, - sandboxSessionId: config.sandboxSessionId, sandboxCwd: config.sandboxCwd, updatedAt: Date.now(), }) diff --git a/packages/server/src/middleware/mcp/cloudbase/README.md b/packages/server/src/middleware/mcp/cloudbase/README.md index 02b9de6..9f20a92 100644 --- a/packages/server/src/middleware/mcp/cloudbase/README.md +++ b/packages/server/src/middleware/mcp/cloudbase/README.md @@ -85,7 +85,8 @@ export const policy: McpPolicy = { | `auth.ts` | `auth`(原生) | **[CORE]** 拦截 | action=start_auth → 重新注入凭证 | | `downloadTemplate.ts` | `downloadTemplate`(原生) | **[CORE]** 拦截 | 强制 ide=codebuddy | | `uploadFiles.ts` | `uploadFiles`(原生) | **[CORE]** 拦截 | 部署成功后产出 artifact | -| `publishMiniprogram.ts` | `publishMiniprogram`(新增) | **[CORE]** augment | 调沙箱 /api/miniprogram/deploy | +| `publishMiniprogram.ts` | `publishMiniprogram`(新增) | **[CORE]** augment | 沙箱业务镜像 `POST /api/jobs/miniprogram-deploy` | +| `getDeployJobStatus.ts` | `getDeployJobStatus`(新增) | **[CORE]** augment | 沙箱业务镜像 `GET /api/jobs/:jobId` | | `getDeployJobStatus.ts` | `getDeployJobStatus`(新增) | **[CORE]** augment | 查询小程序部署状态 | | `cronTask.ts` | `cronTask`(新增) | **[CORE]** augment | 本地 DB + cron-scheduler | @@ -187,7 +188,7 @@ middleware/mcp/cloudbase/ # CloudBase 专用,所有 policy 平铺在 └── cronTask.ts # [CORE] routes/cloudbase-mcp.ts # OpenCode HTTP runtime 入口 -sandbox/sandbox-mcp-proxy.ts # CodeBuddy SDK runtime 入口 +sandbox/stateful/stateful-mcp-client.ts # CodeBuddy SDK runtime(经沙箱业务镜像数据面) # 两条路径都用 lib/cloudbase-mcp-utils + middleware/mcp/cloudbase ``` diff --git a/packages/server/src/middleware/mcp/cloudbase/_index.ts b/packages/server/src/middleware/mcp/cloudbase/_index.ts index 44816a7..62ca03d 100644 --- a/packages/server/src/middleware/mcp/cloudbase/_index.ts +++ b/packages/server/src/middleware/mcp/cloudbase/_index.ts @@ -16,6 +16,12 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { createPolicyLoader, runWithPolicy, runAugmentedTool, isToolHidden } from '../../../lib/mcp-middleware/index.js' import type { McpContext, McpPolicy as GenericMcpPolicy } from '../../../lib/mcp-middleware/index.js' +import { policy as authPolicy } from './auth.js' +import { policy as cronTaskPolicy } from './cronTask.js' +import { policy as downloadTemplatePolicy } from './downloadTemplate.js' +import { policy as getDeployJobStatusPolicy } from './getDeployJobStatus.js' +import { policy as publishMiniprogramPolicy } from './publishMiniprogram.js' +import { policy as uploadFilesPolicy } from './uploadFiles.js' /** CloudBase 特有的上下文扩展字段 */ export interface CloudbaseExtra { @@ -64,10 +70,24 @@ const parseList = (raw: string | undefined): string[] => .map((s) => s.trim()) .filter(Boolean) -const loader = createPolicyLoader(__dirname, 'cloudbase-mcp-policies', { - denyList: parseList(process.env.CLOUDBASE_MCP_DISABLE_TOOLS), - allowList: parseList(process.env.CLOUDBASE_MCP_ENABLE_TOOLS), -}) +const CLOUDBASE_STATIC_POLICIES: Record = { + auth: authPolicy, + cronTask: cronTaskPolicy, + downloadTemplate: downloadTemplatePolicy, + getDeployJobStatus: getDeployJobStatusPolicy, + publishMiniprogram: publishMiniprogramPolicy, + uploadFiles: uploadFilesPolicy, +} + +const loader = createPolicyLoader( + __dirname, + 'cloudbase-mcp-policies', + { + denyList: parseList(process.env.CLOUDBASE_MCP_DISABLE_TOOLS), + allowList: parseList(process.env.CLOUDBASE_MCP_ENABLE_TOOLS), + }, + CLOUDBASE_STATIC_POLICIES, +) export const loadAllPolicies = () => loader.loadAll() export const listPolicies = () => loader.listPolicies() diff --git a/packages/server/src/middleware/mcp/cloudbase/getDeployJobStatus.ts b/packages/server/src/middleware/mcp/cloudbase/getDeployJobStatus.ts index ee7ded0..73420c3 100644 --- a/packages/server/src/middleware/mcp/cloudbase/getDeployJobStatus.ts +++ b/packages/server/src/middleware/mcp/cloudbase/getDeployJobStatus.ts @@ -1,12 +1,11 @@ /** * Augmented tool: getDeployJobStatus * - * 查询 publishMiniprogram 异步返回的 jobId。 - * - * 历史背景:原本在 sandbox-mcp-proxy.ts 中硬编码(line 507-527 DEPLOY_STATUS_*)。 + * Poll 沙箱业务镜像: GET /api/jobs/:jobId (replaces /api/miniprogram/deploy/status) */ import type { McpPolicy } from './_index.js' +import { pollTrwMiniprogramJob } from '../../../sandbox/trw-miniprogram-client.js' export const policy: McpPolicy = { description: 'Query miniprogram deploy job status by jobId', @@ -25,13 +24,11 @@ export const policy: McpPolicy = { async use(ctx) { const jobId = ctx.input.jobId as string try { - const res = await ctx.extra.sandboxFetch(`/api/miniprogram/deploy/status?jobId=${encodeURIComponent(jobId)}`, { - signal: AbortSignal.timeout(30_000), - }) - const body = (await res.json().catch(() => null)) as any - return JSON.stringify(body ?? { error: true, status: res.status }) - } catch (e: any) { - return JSON.stringify({ error: true, message: e.message }) + const polled = await pollTrwMiniprogramJob(ctx.extra.sandboxFetch, jobId) + return JSON.stringify(polled.body ?? { error: true, status: polled.httpStatus }) + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e) + return JSON.stringify({ error: true, message }) } }, } diff --git a/packages/server/src/middleware/mcp/cloudbase/publishMiniprogram.ts b/packages/server/src/middleware/mcp/cloudbase/publishMiniprogram.ts index e8192bc..20efbc6 100644 --- a/packages/server/src/middleware/mcp/cloudbase/publishMiniprogram.ts +++ b/packages/server/src/middleware/mcp/cloudbase/publishMiniprogram.ts @@ -1,13 +1,13 @@ /** * Augmented tool: publishMiniprogram * - * 完全新增的工具(原 mcporter 没有)。负责调用沙箱 /api/miniprogram/deploy 接口 - * 完成小程序预览/上传,并触发 artifact 给前端展示二维码。 - * - * 历史背景:原本在 sandbox-mcp-proxy.ts 中硬编码(line 374-505 PUBLISH_MP_*)。 + * Calls 沙箱业务镜像 vibecoding jobs API: + * POST /api/jobs/miniprogram-deploy + * Poll via getDeployJobStatus → GET /api/jobs/:jobId */ import type { McpPolicy } from './_index.js' +import { miniprogramStartToJson, startTrwMiniprogramDeploy } from '../../../sandbox/trw-miniprogram-client.js' export const policy: McpPolicy = { description: 'Deploy WeChat miniprogram (preview / upload)', @@ -55,71 +55,54 @@ export const policy: McpPolicy = { } try { - const res = await ctx.extra.sandboxFetch('/api/miniprogram/deploy', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - appid: args.appId, - privateKey: creds.privateKey, - action: args.action, - projectPath: args.projectPath, - version: args.version, - description: args.description, - robot: args.robot, - }), - signal: AbortSignal.timeout(120_000), + const outcome = await startTrwMiniprogramDeploy(ctx.extra.sandboxFetch, { + appid: args.appId, + privateKey: creds.privateKey, + action: args.action, + projectPath: args.projectPath, + version: args.version, + description: args.description, + robot: args.robot, }) - const body = (await res.json().catch(() => null)) as any - - if (!res.ok || !body) { - return JSON.stringify({ - error: true, - status: res.status, - message: body?.error || body?.message || `HTTP ${res.status}`, - }) + if (!outcome.ok) { + return miniprogramStartToJson(outcome) } - if (body.async) { - return JSON.stringify({ - async: true, - jobId: body.jobId, - message: '部署仍在进行中,请稍后使用 getDeployJobStatus 工具查询结果', - }) + const envelope = outcome.envelope + if (envelope.async) { + return miniprogramStartToJson(outcome) } - if (!body.success) { - return JSON.stringify({ - error: true, - message: body.error || body.result?.errMsg || 'Deploy failed', - result: body.result, - }) + if (!envelope.success) { + return miniprogramStartToJson(outcome) } - // 成功 → artifact const onArtifact = ctx.extra.onArtifact + const result = envelope.result as { qrcode?: { mimeType?: string; base64?: string } } | undefined if (onArtifact) { - if (body.result?.qrcode) { - const qrcode = `data:${body.result.qrcode.mimeType || 'image/png'};base64,${body.result.qrcode.base64}` + if (result?.qrcode?.base64) { + const qrcode = `data:${result.qrcode.mimeType || 'image/png'};base64,${result.qrcode.base64}` onArtifact({ title: '小程序预览二维码', contentType: 'image', data: qrcode, - metadata: { deploymentType: 'miniprogram', ...body }, + metadata: { deploymentType: 'miniprogram', result: envelope.result }, }) } else if (args.action === 'upload') { onArtifact({ title: '小程序上传成功', contentType: 'json', - data: JSON.stringify(body), + data: JSON.stringify(envelope), metadata: { deploymentType: 'miniprogram', appId: args.appId }, }) } } - return JSON.stringify(body) - } catch (e: any) { - return JSON.stringify({ error: true, message: e.message }) + return JSON.stringify(envelope) + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e) + return JSON.stringify({ error: true, message }) } }, } diff --git a/packages/server/src/routes/acp.ts b/packages/server/src/routes/acp.ts index 0fb63e2..29c66de 100644 --- a/packages/server/src/routes/acp.ts +++ b/packages/server/src/routes/acp.ts @@ -851,7 +851,7 @@ async function observeStreamWithLiveCallback( // This allows the frontend to exit its "busy" state and show the send button. try { const finalRun = getAgentRun(sessionId) - const finalStatus = finalRun?.status === 'error' ? 'error' : 'done' + const finalStatus = finalRun?.status === 'error' ? 'error' : 'completed' await getDb().tasks.update(sessionId, { status: finalStatus, updatedAt: Date.now() }) } catch { // Non-critical — frontend will eventually poll the task and reconcile diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts index 0631707..8fb850a 100644 --- a/packages/server/src/routes/admin.ts +++ b/packages/server/src/routes/admin.ts @@ -4,6 +4,7 @@ import { requireAdmin, type AppEnv } from '../middleware/admin' import { issueTempCredentials } from '../middleware/auth.js' import { provisionUserResources, destroyProvisionedResources } from '../cloudbase/provision.js' import { getProvisionMode, isValidProvisionMode, resolveProvisionMode } from '../lib/provision-config.js' +import { isValidSandboxInstanceMode, resolveSandboxInstanceMode } from '../lib/sandbox-config.js' import { persistenceService } from '../agent/persistence.service.js' import { nanoid } from 'nanoid' import bcrypt from 'bcryptjs' @@ -959,6 +960,9 @@ admin.get('/system-settings', async (c) => { const pm = await resolveProvisionMode() settingsMap['provision_mode'] = pm.value + const sim = await resolveSandboxInstanceMode() + settingsMap['sandbox_instance_mode'] = sim.value + return c.json({ settings: settingsMap, meta: { @@ -966,6 +970,10 @@ admin.get('/system-settings', async (c) => { source: pm.source, // 'db' | 'env' | 'default' envDefault: pm.envDefault, // 用户重置后会回落到的值 }, + sandbox_instance_mode: { + source: sim.source, + envDefault: sim.envDefault, + }, }, }) } catch (e: any) { @@ -989,6 +997,11 @@ admin.put('/system-settings/:key', async (c) => { return c.json({ error: 'Invalid provision mode. Must be: shared, isolated, or task' }, 400) } } + if (key === 'sandbox_instance_mode') { + if (!isValidSandboxInstanceMode(value)) { + return c.json({ error: 'Invalid sandbox instance mode. Must be: shared or isolated' }, 400) + } + } const db = getDb() const setting = await db.settings.upsertSystemSetting(key, value) diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index b87f9bf..3fefe4a 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -170,7 +170,26 @@ auth.post('/register', async (c) => { }) } catch (error) { console.error('Error registering local user:', error) - return c.json({ error: 'Registration failed' }, 500) + const msg = (error as Error).message || '' + if (msg.includes('Invalid Content Encryption Key') || msg.includes('JWE')) { + return c.json( + { + error: + 'Session encryption misconfigured: JWE_SECRET must be 32 random bytes encoded as base64 (see init.sh / .env.example), not hex.', + }, + 500, + ) + } + if (msg.includes('Missing JWE secret')) { + return c.json({ error: 'Server missing JWE_SECRET in .env.local' }, 500) + } + return c.json( + { + error: 'Registration failed', + ...(process.env.NODE_ENV !== 'production' && msg ? { detail: msg } : {}), + }, + 500, + ) } }) diff --git a/packages/server/src/routes/cloudbase-mcp.ts b/packages/server/src/routes/cloudbase-mcp.ts index 671edfb..7d5db6c 100644 --- a/packages/server/src/routes/cloudbase-mcp.ts +++ b/packages/server/src/routes/cloudbase-mcp.ts @@ -133,9 +133,15 @@ async function buildMcpServer( tools = await discoverTools({ bash: (cmd, t) => sandboxBash(sandboxUrl, sandboxAuth, cmd, t ?? 25_000), readJsonFile: async (p) => { - const res = await sandboxFetch(sandboxUrl, sandboxAuth, `/e2b-compatible/files?path=${encodeURIComponent(p)}`) + const res = await sandboxFetch(sandboxUrl, sandboxAuth, '/api/tools/read', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: p }), + }) if (!res.ok) throw new Error(`Failed to read schema file: ${res.status}`) - return res.json() + const data = (await res.json()) as { success?: boolean; result?: { content?: string } } + if (!data.success || !data.result?.content) throw new Error('Empty schema file') + return JSON.parse(data.result.content) }, }) setCachedTools(sessionId, tools) diff --git a/packages/server/src/routes/tasks.ts b/packages/server/src/routes/tasks.ts index 838bf4f..d2a9356 100644 --- a/packages/server/src/routes/tasks.ts +++ b/packages/server/src/routes/tasks.ts @@ -1,16 +1,41 @@ -import { Hono } from 'hono' +import { Hono, type Context } from 'hono' import { streamSSE } from 'hono/streaming' import { getDb } from '../db/index.js' import { nanoid } from 'nanoid' import { requireAuth, requireUserEnv, type AppEnv } from '../middleware/auth' import { readFileSync } from 'node:fs' import { createTaskLogger } from '../lib/task-logger' -import { resolveSandboxConfig, backfillSandboxConfig } from '../lib/sandbox-config' +import { appendAllowedTaskLog } from '../lib/append-task-log.js' +import { isAllowedTaskLogMessage, TASK_LOG } from '@coder/shared' +import type { LogEntry } from '@coder/shared' +import { + STATEFUL_WORKSPACE_ROOT, + resolveSandboxConfig, + resolveSandboxInstanceMode, + resolveSandboxModeForNewTask, + backfillSandboxConfig, + isValidSandboxInstanceMode, +} from '../lib/sandbox-config.js' +import { buildStatefulAcquireContext } from '../sandbox/acquire-context.js' +import { proxyTaskPreview } from '../sandbox/preview-proxy.js' import { decrypt } from '../lib/crypto' import { Octokit } from '@octokit/rest' -import { deleteArchiveBranch, SandboxInstance } from '../sandbox/index.js' +import { deleteArchiveBranch } from '../sandbox/index.js' import { persistenceService } from '../agent/persistence.service' -import { deleteConversationViaSandbox, scfSandboxManager, archiveToGit } from '../sandbox/index.js' +import { + deleteConversationViaSandbox, + archiveToGit, + getSandboxProvider, + statefulProvider, + getTaskSandbox, + runCommandInSandbox, + downloadFileFromSandbox, + readFileFromSandbox, + writeFileToSandbox, + detectPackageManager, + type SandboxInstance, +} from '../sandbox/index.js' +import { isViteReadyResult, waitForSandboxViteReady } from '../sandbox/wait-vite-ready.js' import { destroyProvisionedResources, provisionUserResources, @@ -64,111 +89,7 @@ function parseGitHubUrl(repoUrl: string): { owner: string; repo: string } | null return null } -// --------------------------------------------------------------------------- -// Sandbox helpers -// --------------------------------------------------------------------------- - -interface CommandResult { - success: boolean - exitCode?: number - output?: string - error?: string -} - -async function runCommandInScfSandbox( - sandbox: SandboxInstance, - command: string, - timeout = 30000, -): Promise { - try { - const response = await sandbox.request('/api/tools/bash', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ command, timeout }), - }) - const data = (await response.json()) as { - success: boolean - result?: { output: string; exitCode: number } - error?: string - } - if (!data.success) { - return { success: false, error: data.error || 'Command failed' } - } - return { - success: data.result?.exitCode === 0, - exitCode: data.result?.exitCode, - output: data.result?.output || '', - } - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : 'Command failed' } - } -} - -async function getScfSandbox( - task: Task, - envId: string, - options?: { sandboxMode?: 'shared' | 'isolated'; isCodingMode?: boolean }, -): Promise { - try { - const scfSessionId = task.sandboxSessionId || envId - return ( - (await scfSandboxManager.getExisting(task.id, scfSessionId, { - sandboxMode: (options?.sandboxMode || task.sandboxMode || 'isolated') as 'shared' | 'isolated', - isCodingMode: options?.isCodingMode ?? task.mode === 'coding', - })) ?? null - ) - } catch { - return null - } -} - -type PackageManager = 'pnpm' | 'yarn' | 'npm' - -async function detectPackageManager(sandbox: SandboxInstance): Promise { - const pnpmCheck = await runCommandInScfSandbox(sandbox, 'test -f pnpm-lock.yaml && echo "yes" || echo "no"') - if (pnpmCheck.output?.trim() === 'yes') return 'pnpm' - const yarnCheck = await runCommandInScfSandbox(sandbox, 'test -f yarn.lock && echo "yes" || echo "no"') - if (yarnCheck.output?.trim() === 'yes') return 'yarn' - return 'npm' -} - -async function readFileFromSandbox( - sandbox: SandboxInstance, - filePath: string, - options?: { isImage?: boolean }, -): Promise<{ content: string; found: boolean; isBase64?: boolean }> { - try { - // Use e2b-compatible file read endpoint — returns raw content without line numbers - const response = await sandbox.request(`/e2b-compatible/files?path=${encodeURIComponent(filePath)}`) - if (!response.ok) return { content: '', found: false } - if (options?.isImage) { - const buffer = await response.arrayBuffer() - const content = Buffer.from(buffer).toString('base64') - return { content, found: true, isBase64: true } - } - const content = await response.text() - return { content, found: true } - } catch { - return { content: '', found: false } - } -} - -async function writeFileToSandbox(sandbox: SandboxInstance, filePath: string, content: string): Promise { - try { - // Use e2b-compatible file upload: POST /e2b-compatible/files with FormData - const formData = new FormData() - const blob = new Blob([content], { type: 'application/octet-stream' }) - formData.append('file', blob, filePath) - - const response = await sandbox.request(`/e2b-compatible/files?path=${encodeURIComponent(filePath)}`, { - method: 'POST', - body: formData, - }) - return response.ok - } catch { - return false - } -} +const STATEFUL_DEFAULT_VITE_PORT = 5173 // --------------------------------------------------------------------------- // File helpers @@ -292,7 +213,7 @@ tasksRouter.get('/', async (c) => { const authErr = requireAuth(c) if (authErr) return authErr const session = c.get('session')! - const userTasks = await getDb().tasks.findByUserId(session.user.id) + const userTasks = await getDb().tasks.findAll(500, 0, { userId: session.user.id }) const parsedTasks = userTasks.map((t) => ({ ...t, logs: t.logs ? JSON.parse(t.logs) : [], @@ -318,11 +239,18 @@ tasksRouter.post('/', async (c) => { maxDuration = 300, keepAlive = false, enableBrowser = false, + sandboxMode: bodySandboxMode, } = body if (!prompt || typeof prompt !== 'string') return c.json({ error: 'prompt is required' }, 400) + if (bodySandboxMode != null && !isValidSandboxInstanceMode(String(bodySandboxMode))) { + return c.json({ error: 'sandboxMode must be shared or isolated' }, 400) + } const taskId = body.id || nanoid(12) const now = Date.now() + const defaultSandboxMode = await resolveSandboxModeForNewTask( + typeof bodySandboxMode === 'string' ? bodySandboxMode : null, + ) // 解析 envId:根据 provision_mode 决定 task.envId 怎么来 // - shared: 直接用 TCB_ENV_ID(不写 user_resources) @@ -370,7 +298,12 @@ tasksRouter.post('/', async (c) => { updatedAt: now, }) taskEnvId = result.envId - sandboxConfig = resolveSandboxConfig({ envId: result.envId, taskId }) + sandboxConfig = resolveSandboxConfig({ + envId: result.envId, + taskId, + sandboxMode: defaultSandboxMode, + }) + console.log('[tasks.create] task env ready', { taskId, envId: result.envId }) } catch (err) { console.error('[tasks.create] task env acquire failed:', (err as Error).message) // 回滚已创建的腾讯云资源(best-effort) @@ -387,7 +320,11 @@ tasksRouter.post('/', async (c) => { // shared 模式:直接用支撑账号 env,无需 user_resources if (process.env.TCB_ENV_ID) { taskEnvId = process.env.TCB_ENV_ID - sandboxConfig = resolveSandboxConfig({ envId: process.env.TCB_ENV_ID, taskId }) + sandboxConfig = resolveSandboxConfig({ + envId: process.env.TCB_ENV_ID, + taskId, + sandboxMode: defaultSandboxMode, + }) } } else { // isolated:复用 user-level env;缺失时懒建兜底(覆盖 mode 切换场景) @@ -440,7 +377,11 @@ tasksRouter.post('/', async (c) => { } if (resource?.envId) { taskEnvId = resource.envId - sandboxConfig = resolveSandboxConfig({ envId: resource.envId, taskId }) + sandboxConfig = resolveSandboxConfig({ + envId: resource.envId, + taskId, + sandboxMode: defaultSandboxMode, + }) } } catch (err) { console.error('[tasks.create] isolated user-level lazy provision failed:', (err as Error).message) @@ -469,7 +410,6 @@ tasksRouter.post('/', async (c) => { error: null, branchName: null, sandboxId: null, - sandboxSessionId: sandboxConfig?.sandboxSessionId ?? null, sandboxCwd: sandboxConfig?.sandboxCwd ?? null, sandboxMode: sandboxConfig?.sandboxMode ?? null, agentSessionId: null, @@ -489,6 +429,16 @@ tasksRouter.post('/', async (c) => { return c.json({ task: { ...newTask, logs: [], mcpServerIds: null } }) }) +// Sandbox instance mode for UI copy (DB > env > default) +tasksRouter.get('/sandbox-policy', requireUserEnv, async (c) => { + const mode = await resolveSandboxInstanceMode() + return c.json({ + sandboxInstanceMode: mode.value, + source: mode.source, + envDefault: mode.envDefault, + }) +}) + // Get single task tasksRouter.get('/:taskId', async (c) => { const authErr = requireAuth(c) @@ -519,7 +469,7 @@ tasksRouter.patch('/:taskId', async (c) => { if (body.action === 'stop') { if (existing.status !== 'processing') return c.json({ error: 'Can only stop processing tasks' }, 400) const logger = createTaskLogger(taskId) - await logger.info('Task stopped by user') + await logger.info(TASK_LOG.PLATFORM_TASK_STOPPED) await logger.updateStatus('stopped', 'Task was stopped by user') const updated = await getDb().tasks.findById(taskId) return c.json({ message: 'Task stopped', task: updated }) @@ -527,18 +477,59 @@ tasksRouter.patch('/:taskId', async (c) => { return c.json({ error: 'Invalid action' }, 400) }) -// Delete task (soft delete + git archive cleanup) -// -// 销毁顺序:先清云资源,确认全部清完后才软删 task。云资源未清完(如 env 仍在初始化) -// 视为本次删除失败,task 保留可见,user_resources 保留可重试。 -tasksRouter.delete('/:taskId', requireUserEnv, async (c) => { - const session = c.get('session')! - const { envId } = c.get('userEnv')! - const { taskId } = c.req.param() - const existing = await getDb().tasks.findByIdAndUserId(taskId, session.user.id) - if (!existing || existing.deletedAt) return c.json({ error: 'Task not found' }, 404) +/** Task.status values that bulk delete may target (aliases: failed→error, done→completed). */ +const BULK_DELETE_STATUSES = new Set(['completed', 'done', 'error', 'failed', 'stopped', 'processing', 'pending']) + +function resolveBulkDeleteStatuses(action: string): Set { + const out = new Set() + for (const raw of action + .split(',') + .map((s) => s.trim()) + .filter(Boolean)) { + if (raw === 'failed') { + out.add('error') + continue + } + if (raw === 'completed') { + out.add('completed') + out.add('done') + continue + } + if (BULK_DELETE_STATUSES.has(raw)) out.add(raw) + } + return out +} + +type DeleteTaskFailure = { + status: number + body: Record +} + +type DeleteTaskSuccess = { + ok: true + warning?: string + provisionFailed?: Awaited>['failed'] +} + +async function cleanupSandboxAfterTaskDelete(existing: Task, envId: string): Promise { + const sandbox = await getTaskSandbox(existing, envId).catch(() => null) + if (!sandbox) return + if (existing.sandboxMode === 'isolated') { + const provider = getSandboxProvider() + if (provider.destroy) await provider.destroy(sandbox) + } else { + await deleteConversationViaSandbox(sandbox, envId, existing.id, existing.sandboxCwd || undefined) + } +} + +async function deleteTaskForUser( + existing: Task, + envId: string, + opts?: { awaitSandbox?: boolean; skipSandboxCleanup?: boolean }, +): Promise { + const taskId = existing.id + let provisionFailed: DeleteTaskSuccess['provisionFailed'] - // Step 1: 同步清理 task-scoped 云资源(仅 task 模式下存在) const taskResource = await getDb().userResources.findByTaskId(taskId) if (taskResource && taskResource.scope === 'task') { let result: Awaited> @@ -549,65 +540,212 @@ tasksRouter.delete('/:taskId', requireUserEnv, async (c) => { envId: taskResource.envId, cosTagValue: taskResource.cosTagValue, }) - } catch (e: any) { - console.warn('[task-delete] releaseEnv threw', { message: e?.message }) - return c.json( - { - error: '云资源清理失败,请稍后重试', - detail: e?.message, + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e) + console.warn('[task-delete] releaseEnv threw', { message }) + return { + ok: false, + failure: { + status: 500, + body: { error: '云资源清理失败,请稍后重试', detail: message }, }, - 500, - ) + } } if (result.failed.length > 0) { - console.warn('[task-delete] some resources failed to destroy, keeping task & user_resources', { + provisionFailed = result.failed + console.warn('[task-delete] some resources failed to destroy; soft-deleting task anyway', { failed: result.failed, }) - return c.json( - { - error: '部分云资源清理失败,请稍后重试', - failed: result.failed, - }, - 409, - ) - } - // 云资源全清干净,删 user_resources DB row - try { - await getDb().userResources.deleteById(taskResource.id) - } catch (e: any) { - console.warn('[task-delete] failed to delete user_resources row', { message: e?.message }) - // 云资源已清,但 DB row 删不掉 —— 仍允许 task 软删(避免用户卡住);下次会被孤立检测脚本清掉 + } else { + try { + await getDb().userResources.deleteById(taskResource.id) + console.log('[task-delete] user_resources row removed', { resourceId: taskResource.id }) + } catch (e: unknown) { + console.warn('[task-delete] failed to delete user_resources row', { + message: e instanceof Error ? e.message : String(e), + }) + } } } - // Step 2: 云资源已清干净(或本来就没有 task-scope 资源),软删 task await getDb().tasks.softDelete(taskId) - // conversationId === taskId (ACP convention) - // Try to clean up via sandbox (rm -rf workspace dir + git archive sync); fall back to direct API delete - ;(async () => { - try { - const { sandboxMode = 'isolated' } = existing - if (sandboxMode === 'isolated') { - await deleteArchiveBranch(taskId) - } else { - const scfSessionId = existing.sandboxSessionId || envId - const sandbox = await scfSandboxManager - .getExisting(taskId, scfSessionId, { - sandboxMode: existing.sandboxMode || undefined, - isCodingMode: existing.mode === 'code', - }) - .catch(() => null) - if (sandbox) { - await deleteConversationViaSandbox(sandbox, envId, taskId, existing.sandboxCwd || undefined) + if (!opts?.skipSandboxCleanup) { + if (opts?.awaitSandbox) { + try { + await cleanupSandboxAfterTaskDelete(existing, envId) + } catch { + console.log('clean conversation workspace error') + } + } else { + void cleanupSandboxAfterTaskDelete(existing, envId).catch(() => { + console.log('clean conversation workspace error') + }) + } + } + + if (provisionFailed?.length) { + return { + ok: true, + warning: '部分云资源清理失败,任务已从列表移除,后台可重试清理', + provisionFailed, + } + } + return { ok: true } +} + +/** Parallel DB/provision deletes; isolated sandbox stops use SANDBOX_DESTROY_CONCURRENCY (lower for AGS). */ +const DELETE_ALL_CONCURRENCY = Math.min(16, Math.max(1, Number(process.env.DELETE_ALL_CONCURRENCY) || 8)) +const SANDBOX_DESTROY_CONCURRENCY = Math.min(8, Math.max(1, Number(process.env.SANDBOX_DESTROY_CONCURRENCY) || 4)) + +async function deleteTasksInBatch( + toDelete: Task[], + envId: string, + opts: { awaitSandbox?: boolean; skipSandboxCleanup?: boolean }, +): Promise<{ + deleted: number + deletedTasks: Task[] + failures: Array<{ taskId: string; error: string; detail?: string }> +}> { + let deleted = 0 + const deletedTasks: Task[] = [] + const failures: Array<{ taskId: string; error: string; detail?: string }> = [] + + for (let i = 0; i < toDelete.length; i += DELETE_ALL_CONCURRENCY) { + const batch = toDelete.slice(i, i + DELETE_ALL_CONCURRENCY) + const results = await Promise.all(batch.map((task) => deleteTaskForUser(task, envId, opts))) + for (let j = 0; j < batch.length; j++) { + const task = batch[j]! + const result = results[j]! + if (result.ok) { + deleted += 1 + deletedTasks.push(task) + if (result.warning) { + failures.push({ taskId: task.id, error: result.warning }) } + } else { + const body = result.failure.body + failures.push({ + taskId: task.id, + error: String(body.error ?? 'delete failed'), + detail: typeof body.detail === 'string' ? body.detail : undefined, + }) } - } catch (e) { - console.log('clean conversation workspace error') + } + } + + return { deleted, deletedTasks, failures } +} + +/** Isolated-only: stop per-task AGS instances after bulk soft-delete (shared uses stopSharedEnvSandbox). */ +function scheduleSandboxCleanupAfterDeleteAll(deletedTasks: Task[], envId: string, instanceMode: string): void { + if (deletedTasks.length === 0 || instanceMode === 'shared') return + void (async () => { + for (let i = 0; i < deletedTasks.length; i += SANDBOX_DESTROY_CONCURRENCY) { + const batch = deletedTasks.slice(i, i + SANDBOX_DESTROY_CONCURRENCY) + await Promise.allSettled(batch.map((task) => cleanupSandboxAfterTaskDelete(task, envId))) } })() +} + +tasksRouter.delete('/', requireUserEnv, async (c) => { + const session = c.get('session')! + const { envId } = c.get('userEnv')! + const action = c.req.query('action') ?? '' + + if (action === 'all') { + const candidates = await getDb().tasks.findAll(500, 0, { userId: session.user.id }) + const toDelete = candidates.filter((t) => !t.deletedAt) + if (toDelete.length === 0) { + return c.json({ message: '没有可删除的任务', deleted: 0, sharedSandboxStopped: false, failed: [] }) + } + + const instanceMode = (await resolveSandboxInstanceMode()).value + // Bulk: never per-task sandbox work in deleteTaskForUser — shared stops once below; isolated in background. + const { deleted, deletedTasks, failures } = await deleteTasksInBatch(toDelete, envId, { + awaitSandbox: false, + skipSandboxCleanup: true, + }) + + scheduleSandboxCleanupAfterDeleteAll(deletedTasks, envId, instanceMode) + + let sharedSandboxStopped = false + if (instanceMode === 'shared' && deleted > 0) { + try { + sharedSandboxStopped = await statefulProvider.stopSharedEnvSandbox(envId) + if (sharedSandboxStopped && deletedTasks[0]) { + void appendAllowedTaskLog(deletedTasks[0].id, 'info', TASK_LOG.PLATFORM_SHARED_SANDBOX_STOPPED) + } + } catch (err) { + failures.push({ + taskId: '*', + error: '停止共享沙箱实例失败', + detail: err instanceof Error ? err.message : String(err), + }) + } + } + + if (deleted === 0 && failures.length > 0) { + return c.json({ error: '未能删除任何任务', deleted, failed: failures, sharedSandboxStopped }, 409) + } + + return c.json({ + message: `已删除 ${deleted} 个任务`, + deleted, + failed: failures, + sharedSandboxStopped, + sandboxInstanceMode: instanceMode, + }) + } + + const statusSet = resolveBulkDeleteStatuses(action) + + if (statusSet.size === 0) { + return c.json( + { error: 'Invalid or empty action query (e.g. ?action=all or ?action=completed,failed,stopped)' }, + 400, + ) + } + const candidates = await getDb().tasks.findAll(500, 0, { userId: session.user.id }) + const toDelete = candidates.filter((t) => statusSet.has(t.status)) + + const { deleted, failures } = await deleteTasksInBatch(toDelete, envId, { awaitSandbox: false }) + + if (deleted === 0 && failures.length > 0) { + return c.json( + { + error: '未能删除任何任务', + deleted, + failed: failures, + }, + 409, + ) + } + + return c.json({ + message: `Deleted ${deleted} task${deleted === 1 ? '' : 's'}`, + deleted, + failed: failures, + }) +}) + +// Delete task (soft delete + git archive cleanup) +tasksRouter.delete('/:taskId', requireUserEnv, async (c) => { + const session = c.get('session')! + const { envId } = c.get('userEnv')! + const { taskId } = c.req.param() + const existing = await getDb().tasks.findByIdAndUserId(taskId, session.user.id) + if (!existing || existing.deletedAt) return c.json({ error: 'Task not found' }, 404) + + const result = await deleteTaskForUser(existing, envId) + if (!result.ok) { + return c.json(result.failure.body, result.failure.status as 409 | 500) + } - return c.json({ message: 'Task deleted' }) + return c.json({ + message: 'Task deleted', + ...(result.warning ? { warning: result.warning, failed: result.provisionFailed } : {}), + }) }) // Get task messages @@ -790,7 +928,7 @@ tasksRouter.get('/:taskId/files', requireUserEnv, async (c) => { if (mode === 'local') { if (!task.sandboxId) return c.json({ success: false, error: 'Sandbox is not running' }, 410) try { - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (!sandbox) return c.json({ success: true, @@ -800,7 +938,7 @@ tasksRouter.get('/:taskId/files', requireUserEnv, async (c) => { message: 'Sandbox not found', }) - const statusResult = await runCommandInScfSandbox(sandbox, 'git status --porcelain') + const statusResult = await runCommandInSandbox(sandbox, 'git status --porcelain') if (!statusResult.success) return c.json({ success: true, @@ -815,13 +953,10 @@ tasksRouter.get('/:taskId/files', requireUserEnv, async (c) => { .trim() .split('\n') .filter((line) => line.trim()) - const checkRemoteResult = await runCommandInScfSandbox( - sandbox, - `git rev-parse --verify origin/${task.branchName}`, - ) + const checkRemoteResult = await runCommandInSandbox(sandbox, `git rev-parse --verify origin/${task.branchName}`) const remoteBranchExists = checkRemoteResult.success const compareRef = remoteBranchExists ? `origin/${task.branchName}` : 'HEAD' - const numstatResult = await runCommandInScfSandbox(sandbox, `git diff --numstat ${compareRef}`) + const numstatResult = await runCommandInSandbox(sandbox, `git diff --numstat ${compareRef}`) const diffStats: Record = {} if (numstatResult.success) { const numstatOutput = numstatResult.output || '' @@ -852,7 +987,7 @@ tasksRouter.get('/:taskId/files', requireUserEnv, async (c) => { (indexStatus === '?' && worktreeStatus === '?') || (indexStatus === 'A' && !stats.additions && !stats.deletions) ) { - const wcResult = await runCommandInScfSandbox(sandbox, `wc -l '${filename.replace(/'/g, "'\\''")}'`) + const wcResult = await runCommandInSandbox(sandbox, `wc -l '${filename.replace(/'/g, "'\\''")}'`) if (wcResult.success) { stats = { additions: parseInt((wcResult.output || '').trim().split(/\s+/)[0]) || 0, deletions: 0 } } @@ -872,7 +1007,7 @@ tasksRouter.get('/:taskId/files', requireUserEnv, async (c) => { } else if (mode === 'all-local') { if (!task.sandboxId) return c.json({ success: false, error: 'Sandbox is not running' }, 410) try { - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (!sandbox) return c.json({ success: true, @@ -881,7 +1016,7 @@ tasksRouter.get('/:taskId/files', requireUserEnv, async (c) => { branchName: task.branchName, message: 'Sandbox not found', }) - const findResult = await runCommandInScfSandbox( + const findResult = await runCommandInSandbox( sandbox, "find . -type f -not -path '*/.git/*' -not -path '*/node_modules/*' -not -path '*/.next/*' -not -path '*/dist/*' -not -path '*/build/*' -not -path '*/.vercel/*'", ) @@ -899,7 +1034,7 @@ tasksRouter.get('/:taskId/files', requireUserEnv, async (c) => { .split('\n') .filter((line) => line.trim() && line !== '.') .map((line) => line.replace(/^\.\//, '')) - const statusResult = await runCommandInScfSandbox(sandbox, 'git status --porcelain') + const statusResult = await runCommandInSandbox(sandbox, 'git status --porcelain') const changedFilesMap: Record = {} if (statusResult.success) { const statusOutput = statusResult.output || '' @@ -1066,7 +1201,7 @@ tasksRouter.get('/:taskId/files/list-dir', requireUserEnv, async (c) => { if (!task) return c.json({ success: false, error: 'Task not found' }, 404) if (!task.sandboxId) return c.json({ success: false, error: 'Sandbox is not running' }, 410) - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (!sandbox) return c.json({ success: false, error: 'Sandbox not found' }, 410) // Sanitize path to prevent directory traversal @@ -1074,7 +1209,7 @@ tasksRouter.get('/:taskId/files/list-dir', requireUserEnv, async (c) => { const targetPath = safePath || '.' // List single directory level: -1 = one entry per line, -A = exclude . and .. - const lsResult = await runCommandInScfSandbox(sandbox, `ls -1AF '${targetPath.replace(/'/g, "'\\''")}'`) + const lsResult = await runCommandInSandbox(sandbox, `ls -1AF '${targetPath.replace(/'/g, "'\\''")}'`) if (!lsResult.success) { return c.json({ success: false, error: 'Failed to list directory' }, 500) } @@ -1136,7 +1271,7 @@ tasksRouter.get('/:taskId/file-content', requireUserEnv, async (c) => { // For local/sandbox mode, read directly from sandbox without requiring branch/repo if (mode === 'local' && task.sandboxId && (!task.branchName || !task.repoUrl)) { - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (!sandbox) return c.json({ error: 'Sandbox not found' }, 410) const normalizedPath = filename.startsWith('/') ? filename.substring(1) : filename const isImage = isImageFile(filename) @@ -1208,7 +1343,7 @@ tasksRouter.get('/:taskId/file-content', requireUserEnv, async (c) => { } if (task.sandboxId) { try { - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (sandbox) { const normalizedPath = filename.startsWith('/') ? filename.substring(1) : filename const result = await readFileFromSandbox(sandbox, normalizedPath, { isImage }) @@ -1227,7 +1362,7 @@ tasksRouter.get('/:taskId/file-content', requireUserEnv, async (c) => { let content = '' if (isNodeModulesFile && task.sandboxId) { try { - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (sandbox) { const normalizedPath = filename.startsWith('/') ? filename.substring(1) : filename const result = await readFileFromSandbox(sandbox, normalizedPath, { isImage }) @@ -1248,7 +1383,7 @@ tasksRouter.get('/:taskId/file-content', requireUserEnv, async (c) => { } if (!fileFound && !isImage && !isNodeModulesFile && task.sandboxId) { try { - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (sandbox) { const normalizedPath = filename.startsWith('/') ? filename.substring(1) : filename const result = await readFileFromSandbox(sandbox, normalizedPath, { isImage }) @@ -1288,29 +1423,41 @@ tasksRouter.get('/:taskId/file-content', requireUserEnv, async (c) => { // POST /:taskId/save-file // --------------------------------------------------------------------------- tasksRouter.post('/:taskId/save-file', requireUserEnv, async (c) => { + const { taskId } = c.req.param() try { const session = c.get('session')! const { envId } = c.get('userEnv')! - const { taskId } = c.req.param() const body = await c.req.json() const { filename, content } = body if (!filename || content === undefined) return c.json({ error: 'Missing filename or content' }, 400) const task = await findActiveTask(taskId, session.user.id) if (!task) return c.json({ error: 'Task not found' }, 404) - if (!task.sandboxId) return c.json({ error: 'Task does not have an active sandbox' }, 400) - const sandbox = await getScfSandbox(task, envId) - if (!sandbox) return c.json({ error: 'Sandbox not available' }, 400) + if (!task.sandboxId) { + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_NO_SANDBOX) + return c.json({ error: 'Task does not have an active sandbox' }, 400) + } + const sandbox = await getTaskSandbox(task, envId) + if (!sandbox) { + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_NO_SANDBOX) + return c.json({ error: 'Sandbox not available' }, 400) + } const success = await writeFileToSandbox(sandbox, filename, content) - if (!success) return c.json({ error: 'Failed to write file to sandbox' }, 500) + if (!success) { + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_FILE_SAVE_FAILED) + return c.json({ error: 'Failed to write file to sandbox' }, 500) + } - // Persist changes to git archive in background (don't block response) - archiveToGit(sandbox, taskId, `Edit ${filename}`).catch(() => { - // Non-critical: file is saved in sandbox, git push is best-effort + await appendAllowedTaskLog(taskId, 'success', TASK_LOG.WORKSPACE_FILE_SAVED) + + void archiveToGit(sandbox, taskId, 'workspace file save').then((result) => { + if (result === 'ok') void appendAllowedTaskLog(taskId, 'info', TASK_LOG.PLATFORM_ARCHIVE_PUSH_OK) + else if (result === 'fail') void appendAllowedTaskLog(taskId, 'error', TASK_LOG.PLATFORM_ARCHIVE_PUSH_FAILED) }) return c.json({ success: true, message: 'File saved successfully' }) } catch (error) { console.error('Error in save-file API:', error) + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_FILE_SAVE_FAILED) return c.json({ error: 'Internal server error' }, 500) } }) @@ -1319,28 +1466,42 @@ tasksRouter.post('/:taskId/save-file', requireUserEnv, async (c) => { // POST /:taskId/create-file // --------------------------------------------------------------------------- tasksRouter.post('/:taskId/create-file', requireUserEnv, async (c) => { + const { taskId } = c.req.param() try { const session = c.get('session')! const { envId } = c.get('userEnv')! - const { taskId } = c.req.param() const body = await c.req.json() const { filename } = body if (!filename || typeof filename !== 'string') return c.json({ success: false, error: 'Filename is required' }, 400) const task = await findActiveTask(taskId, session.user.id) if (!task) return c.json({ success: false, error: 'Task not found' }, 404) - if (!task.sandboxId) return c.json({ success: false, error: 'Sandbox not available' }, 400) - const sandbox = await getScfSandbox(task, envId) - if (!sandbox) return c.json({ success: false, error: 'Sandbox not found or inactive' }, 400) + if (!task.sandboxId) { + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_NO_SANDBOX) + return c.json({ success: false, error: 'Sandbox not available' }, 400) + } + const sandbox = await getTaskSandbox(task, envId) + if (!sandbox) { + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_NO_SANDBOX) + return c.json({ success: false, error: 'Sandbox not found or inactive' }, 400) + } const pathParts = filename.split('/') if (pathParts.length > 1) { const dirPath = pathParts.slice(0, -1).join('/') - const mkdirResult = await runCommandInScfSandbox(sandbox, `mkdir -p '${dirPath.replace(/'/g, "'\\''")}'`) - if (!mkdirResult.success) return c.json({ success: false, error: 'Failed to create parent directories' }, 500) + const mkdirResult = await runCommandInSandbox(sandbox, `mkdir -p '${dirPath.replace(/'/g, "'\\''")}'`) + if (!mkdirResult.success) { + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_FILE_CREATE_FAILED) + return c.json({ success: false, error: 'Failed to create parent directories' }, 500) + } + } + const touchResult = await runCommandInSandbox(sandbox, `touch '${filename.replace(/'/g, "'\\''")}'`) + if (!touchResult.success) { + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_FILE_CREATE_FAILED) + return c.json({ success: false, error: 'Failed to create file' }, 500) } - const touchResult = await runCommandInScfSandbox(sandbox, `touch '${filename.replace(/'/g, "'\\''")}'`) - if (!touchResult.success) return c.json({ success: false, error: 'Failed to create file' }, 500) + await appendAllowedTaskLog(taskId, 'success', TASK_LOG.WORKSPACE_FILE_CREATED) return c.json({ success: true, message: 'File created successfully', filename }) } catch { + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_FILE_CREATE_FAILED) return c.json({ success: false, error: 'An error occurred while creating the file' }, 500) } }) @@ -1349,23 +1510,34 @@ tasksRouter.post('/:taskId/create-file', requireUserEnv, async (c) => { // POST /:taskId/create-folder // --------------------------------------------------------------------------- tasksRouter.post('/:taskId/create-folder', requireUserEnv, async (c) => { + const { taskId } = c.req.param() try { const session = c.get('session')! const { envId } = c.get('userEnv')! - const { taskId } = c.req.param() const body = await c.req.json() const { foldername } = body if (!foldername || typeof foldername !== 'string') return c.json({ success: false, error: 'Foldername is required' }, 400) const task = await findActiveTask(taskId, session.user.id) if (!task) return c.json({ success: false, error: 'Task not found' }, 404) - if (!task.sandboxId) return c.json({ success: false, error: 'Sandbox not available' }, 400) - const sandbox = await getScfSandbox(task, envId) - if (!sandbox) return c.json({ success: false, error: 'Sandbox not found or inactive' }, 400) - const mkdirResult = await runCommandInScfSandbox(sandbox, `mkdir -p '${foldername.replace(/'/g, "'\\''")}'`) - if (!mkdirResult.success) return c.json({ success: false, error: 'Failed to create folder' }, 500) + if (!task.sandboxId) { + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_NO_SANDBOX) + return c.json({ success: false, error: 'Sandbox not available' }, 400) + } + const sandbox = await getTaskSandbox(task, envId) + if (!sandbox) { + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_NO_SANDBOX) + return c.json({ success: false, error: 'Sandbox not found or inactive' }, 400) + } + const mkdirResult = await runCommandInSandbox(sandbox, `mkdir -p '${foldername.replace(/'/g, "'\\''")}'`) + if (!mkdirResult.success) { + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_FOLDER_CREATE_FAILED) + return c.json({ success: false, error: 'Failed to create folder' }, 500) + } + await appendAllowedTaskLog(taskId, 'success', TASK_LOG.WORKSPACE_FOLDER_CREATED) return c.json({ success: true, message: 'Folder created successfully', foldername }) } catch { + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_FOLDER_CREATE_FAILED) return c.json({ success: false, error: 'An error occurred while creating the folder' }, 500) } }) @@ -1374,22 +1546,33 @@ tasksRouter.post('/:taskId/create-folder', requireUserEnv, async (c) => { // DELETE /:taskId/delete-file // --------------------------------------------------------------------------- tasksRouter.delete('/:taskId/delete-file', requireUserEnv, async (c) => { + const { taskId } = c.req.param() try { const session = c.get('session')! const { envId } = c.get('userEnv')! - const { taskId } = c.req.param() const body = await c.req.json() const { filename } = body if (!filename || typeof filename !== 'string') return c.json({ success: false, error: 'Filename is required' }, 400) const task = await findActiveTask(taskId, session.user.id) if (!task) return c.json({ success: false, error: 'Task not found' }, 404) - if (!task.sandboxId) return c.json({ success: false, error: 'Sandbox not available' }, 400) - const sandbox = await getScfSandbox(task, envId) - if (!sandbox) return c.json({ success: false, error: 'Sandbox not found or inactive' }, 400) - const rmResult = await runCommandInScfSandbox(sandbox, `rm '${filename.replace(/'/g, "'\\''")}'`) - if (!rmResult.success) return c.json({ success: false, error: 'Failed to delete file' }, 500) + if (!task.sandboxId) { + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_NO_SANDBOX) + return c.json({ success: false, error: 'Sandbox not available' }, 400) + } + const sandbox = await getTaskSandbox(task, envId) + if (!sandbox) { + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_NO_SANDBOX) + return c.json({ success: false, error: 'Sandbox not found or inactive' }, 400) + } + const rmResult = await runCommandInSandbox(sandbox, `rm '${filename.replace(/'/g, "'\\''")}'`) + if (!rmResult.success) { + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_FILE_DELETE_FAILED) + return c.json({ success: false, error: 'Failed to delete file' }, 500) + } + await appendAllowedTaskLog(taskId, 'success', TASK_LOG.WORKSPACE_FILE_DELETED) return c.json({ success: true, message: 'File deleted successfully', filename }) } catch { + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_FILE_DELETE_FAILED) return c.json({ success: false, error: 'An error occurred while deleting the file' }, 500) } }) @@ -1408,16 +1591,16 @@ tasksRouter.post('/:taskId/discard-file-changes', requireUserEnv, async (c) => { const task = await findActiveTask(taskId, session.user.id) if (!task) return c.json({ success: false, error: 'Task not found' }, 404) if (!task.sandboxId) return c.json({ success: false, error: 'Sandbox not available' }, 400) - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (!sandbox) return c.json({ success: false, error: 'Sandbox not found or inactive' }, 400) const escapedFilename = filename.replace(/'/g, "'\\''") - const lsFilesResult = await runCommandInScfSandbox(sandbox, `git ls-files '${escapedFilename}'`) + const lsFilesResult = await runCommandInSandbox(sandbox, `git ls-files '${escapedFilename}'`) const isTracked = (lsFilesResult.output || '').trim().length > 0 if (isTracked) { - const checkoutResult = await runCommandInScfSandbox(sandbox, `git checkout HEAD -- '${escapedFilename}'`) + const checkoutResult = await runCommandInSandbox(sandbox, `git checkout HEAD -- '${escapedFilename}'`) if (!checkoutResult.success) return c.json({ success: false, error: 'Failed to discard changes' }, 500) } else { - const rmResult = await runCommandInScfSandbox(sandbox, `rm '${escapedFilename}'`) + const rmResult = await runCommandInSandbox(sandbox, `rm '${escapedFilename}'`) if (!rmResult.success) return c.json({ success: false, error: 'Failed to delete file' }, 500) } return c.json({ @@ -1448,16 +1631,13 @@ tasksRouter.get('/:taskId/diff', requireUserEnv, async (c) => { if (mode === 'local') { if (!task.sandboxId) return c.json({ error: 'Sandbox not available' }, 400) try { - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (!sandbox) return c.json({ error: 'Sandbox not found or inactive' }, 400) - await runCommandInScfSandbox(sandbox, `git fetch origin ${task.branchName}`) - const checkRemoteResult = await runCommandInScfSandbox( - sandbox, - `git rev-parse --verify origin/${task.branchName}`, - ) + await runCommandInSandbox(sandbox, `git fetch origin ${task.branchName}`) + const checkRemoteResult = await runCommandInSandbox(sandbox, `git rev-parse --verify origin/${task.branchName}`) const remoteBranchExists = checkRemoteResult.success if (!remoteBranchExists) { - const oldContentResult = await runCommandInScfSandbox(sandbox, `git show HEAD:${filename}`) + const oldContentResult = await runCommandInSandbox(sandbox, `git show HEAD:${filename}`) const oldContent = oldContentResult.success ? oldContentResult.output || '' : '' const newContentFile = await readFileFromSandbox(sandbox, filename) const newContent = newContentFile.found ? newContentFile.content : '' @@ -1474,7 +1654,7 @@ tasksRouter.get('/:taskId/diff', requireUserEnv, async (c) => { }) } const remoteBranchRef = `origin/${task.branchName}` - const oldContentResult = await runCommandInScfSandbox(sandbox, `git show ${remoteBranchRef}:${filename}`) + const oldContentResult = await runCommandInSandbox(sandbox, `git show ${remoteBranchRef}:${filename}`) const oldContent = oldContentResult.success ? oldContentResult.output || '' : '' const newContentFile = await readFileFromSandbox(sandbox, filename) const newContent = newContentFile.found ? newContentFile.content : '' @@ -1633,20 +1813,20 @@ tasksRouter.post('/:taskId/sync-changes', requireUserEnv, async (c) => { if (!task) return c.json({ success: false, error: 'Task not found' }, 404) if (!task.sandboxId) return c.json({ success: false, error: 'Sandbox not available' }, 400) if (!task.branchName) return c.json({ success: false, error: 'Branch not available' }, 400) - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (!sandbox) return c.json({ success: false, error: 'Sandbox not found or inactive' }, 400) - const addResult = await runCommandInScfSandbox(sandbox, 'git add .') + const addResult = await runCommandInSandbox(sandbox, 'git add .') if (!addResult.success) return c.json({ success: false, error: 'Failed to add changes' }, 500) - const statusResult = await runCommandInScfSandbox(sandbox, 'git status --porcelain') + const statusResult = await runCommandInSandbox(sandbox, 'git status --porcelain') if (!statusResult.success) return c.json({ success: false, error: 'Failed to check status' }, 500) const statusOutput = statusResult.output || '' if (!statusOutput.trim()) return c.json({ success: true, message: 'No changes to sync', committed: false, pushed: false }) const message = commitMessage || 'Sync local changes' const escapedMessage = message.replace(/'/g, "'\\''") - const commitResult = await runCommandInScfSandbox(sandbox, `git commit -m '${escapedMessage}'`) + const commitResult = await runCommandInSandbox(sandbox, `git commit -m '${escapedMessage}'`) if (!commitResult.success) return c.json({ success: false, error: 'Failed to commit changes' }, 500) - const pushResult = await runCommandInScfSandbox(sandbox, `git push origin ${task.branchName}`) + const pushResult = await runCommandInSandbox(sandbox, `git push origin ${task.branchName}`) if (!pushResult.success) return c.json({ success: false, error: 'Failed to push changes' }, 500) return c.json({ success: true, message: 'Changes synced successfully', committed: true, pushed: true }) } catch { @@ -1819,7 +1999,7 @@ tasksRouter.get('/:taskId/project-files', requireUserEnv, async (c) => { const task = await findActiveTask(taskId, session.user.id) if (!task) return c.json({ error: 'Task not found' }, 404) if (!task.sandboxId) return c.json({ error: 'Task does not have an active sandbox' }, 400) - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (!sandbox) return c.json({ error: 'Sandbox not available' }, 400) return c.json({ success: true, files: [] }) } catch (error) { @@ -1839,7 +2019,7 @@ tasksRouter.post('/:taskId/lsp', requireUserEnv, async (c) => { const task = await getDb().tasks.findById(taskId) if (!task || task.userId !== session.user.id) return c.json({ error: 'Task not found' }, 404) if (!task.sandboxId) return c.json({ error: 'Task does not have an active sandbox' }, 400) - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (!sandbox) return c.json({ error: 'Sandbox not available' }, 400) const body = await c.req.json() const { method, filename, position } = body @@ -1885,8 +2065,8 @@ if (definitions && definitions.length > 0) { ` const writeSuccess = await writeFileToSandbox(sandbox, scriptPath, helperScript) if (!writeSuccess) return c.json({ definitions: [], error: 'Failed to write helper script' }) - const result = await runCommandInScfSandbox(sandbox, `node ${scriptPath}`) - await runCommandInScfSandbox(sandbox, `rm ${scriptPath}`) + const result = await runCommandInSandbox(sandbox, `node ${scriptPath}`) + await runCommandInSandbox(sandbox, `rm ${scriptPath}`) if (!result.success) return c.json({ definitions: [], error: 'Script execution failed' }) try { return c.json(JSON.parse((result.output || '').trim())) @@ -1920,10 +2100,10 @@ tasksRouter.post('/:taskId/terminal', requireUserEnv, async (c) => { const task = await findActiveTask(taskId, session.user.id) if (!task) return c.json({ success: false, error: 'Task not found' }, 404) if (!task.sandboxId) return c.json({ success: false, error: 'No sandbox found for this task' }, 400) - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (!sandbox) return c.json({ success: false, error: 'Sandbox not available' }, 400) try { - const result = await runCommandInScfSandbox(sandbox, command) + const result = await runCommandInSandbox(sandbox, command) return c.json({ success: true, data: { @@ -1955,10 +2135,10 @@ tasksRouter.post('/:taskId/autocomplete', requireUserEnv, async (c) => { const task = await findActiveTask(taskId, session.user.id) if (!task) return c.json({ success: false, error: 'Task not found' }, 404) if (!task.sandboxId) return c.json({ success: false, error: 'No sandbox found for this task' }, 400) - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (!sandbox) return c.json({ success: false, error: 'Sandbox not available' }, 400) try { - const pwdResult = await runCommandInScfSandbox(sandbox, 'pwd') + const pwdResult = await runCommandInSandbox(sandbox, 'pwd') let actualCwd = cwd || '/home/user' if (pwdResult.success && pwdResult.output && pwdResult.output.trim()) { actualCwd = pwdResult.output.trim() @@ -1979,7 +2159,7 @@ tasksRouter.post('/:taskId/autocomplete', requireUserEnv, async (c) => { } const escapedDir = "'" + dir.replace(/'/g, "'\\''") + "'" const lsCommand = `cd ${escapedDir} 2>/dev/null && ls -1ap 2>/dev/null || echo ""` - const result = await runCommandInScfSandbox(sandbox, lsCommand) + const result = await runCommandInSandbox(sandbox, lsCommand) const stdout = result.output || '' if (!stdout) return c.json({ success: true, data: { completions: [] } }) const completionFiles = stdout @@ -2289,6 +2469,7 @@ tasksRouter.post('/:taskId/deployments', async (c) => { metadata: metadata ? JSON.stringify(metadata) : existing.metadata, updatedAt: now, }) + await appendAllowedTaskLog(taskId, 'success', TASK_LOG.PLATFORM_DEPLOYMENT_RECORDED) return c.json({ deployment: { ...updated, metadata } }) } } else if (type === 'web' && path) { @@ -2300,6 +2481,7 @@ tasksRouter.post('/:taskId/deployments', async (c) => { metadata: metadata ? JSON.stringify(metadata) : existing.metadata, updatedAt: now, }) + await appendAllowedTaskLog(taskId, 'success', TASK_LOG.PLATFORM_DEPLOYMENT_RECORDED) return c.json({ deployment: { ...updated, metadata } }) } } @@ -2318,6 +2500,7 @@ tasksRouter.post('/:taskId/deployments', async (c) => { createdAt: now, updatedAt: now, }) + await appendAllowedTaskLog(taskId, 'success', TASK_LOG.PLATFORM_DEPLOYMENT_RECORDED) return c.json({ deployment: { ...newDeployment, metadata } }) } catch (error) { console.error('Error creating deployment:', error) @@ -2356,9 +2539,9 @@ tasksRouter.get('/:taskId/sandbox-health', requireUserEnv, async (c) => { const task = await findActiveTask(taskId, session.user.id) if (!task) return c.json({ status: 'not_found' }) if (!task.sandboxId) return c.json({ status: 'not_available', message: 'Sandbox not created yet' }) - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (!sandbox) return c.json({ status: 'stopped', message: 'Sandbox not available' }) - const result = await runCommandInScfSandbox(sandbox, 'echo ok') + const result = await runCommandInSandbox(sandbox, 'echo ok') if (result.success) return c.json({ status: 'running', message: 'Sandbox is running' }) return c.json({ status: 'error', message: 'Sandbox is not responding' }) } catch (error) { @@ -2368,182 +2551,44 @@ tasksRouter.get('/:taskId/sandbox-health', requireUserEnv, async (c) => { }) // --------------------------------------------------------------------------- -// GET /:taskId/preview-health — 检查 dev server 是否实际响应(via /api/scope/info) +// GET /:taskId/preview-health — dev server via 沙箱业务镜像 GET /preview/ports // --------------------------------------------------------------------------- -// TEMP: GET /:taskId/container-probe — diagnostic for multi-instance routing. -// 调用 N 次 /health(SCF 直连)+ N 次 /preview/{port}/(公网 gateway),各自 -// 拿到 container 身份: -// - SCF 直连:通过 /health body 的 instance.id 或 X-Sandbox-Instance 响应头 -// - 公网 gateway (502):通过 problem body 的 sandbox_instance 或响应头 -// 比较两侧 container id 集合是否相同,判断 502 是否由跨 container 路由导致。 -tasksRouter.get('/:taskId/container-probe', requireUserEnv, async (c) => { +tasksRouter.get('/:taskId/preview-health', requireUserEnv, async (c) => { try { const session = c.get('session')! const { envId } = c.get('userEnv')! const { taskId } = c.req.param() const task = await findActiveTask(taskId, session.user.id) - if (!task) return c.json({ error: 'not_found' }, 404) - if (!task.sandboxId) return c.json({ error: 'no_sandbox' }, 400) + if (!task) return c.json({ status: 'not_found' }) + if (!task.sandboxId) return c.json({ status: 'no_sandbox' }) const taskMode = (task as any).mode as string | null | undefined const isCodingMode = taskMode === 'coding' - const sandboxConfig = resolveSandboxConfig({ - sandboxMode: task.sandboxMode, - sandboxSessionId: task.sandboxSessionId, - sandboxCwd: task.sandboxCwd, - envId, - taskId, - }) - const sandbox = await getScfSandbox(task, envId, { - sandboxMode: sandboxConfig.sandboxMode, - isCodingMode, - }) - if (!sandbox) return c.json({ error: 'no_sandbox_instance' }, 400) - - // Keep N small + add spacing to avoid SCF gateway rate-limiting (HTTP 430) - const N = 3 - const SPACING_MS = 400 - const resolvedSessionId = sandboxConfig.sandboxSessionId - - type IdResult = { - ok: boolean - sandboxInstance?: string - sandboxPid?: number - sandboxBoot?: string - status?: number - requestId?: string - detail?: string - err?: string - // image version hints - hasSandboxInstanceHeader?: boolean - hasSandboxInstanceBody?: boolean - } - - // 1) SCF 直连 /health - const directResults: IdResult[] = [] - for (let i = 0; i < N; i++) { - try { - const r = await sandbox.request('/health', { signal: AbortSignal.timeout(8_000) }) - const headerInstance = r.headers.get('x-sandbox-instance') || undefined - const headerPid = r.headers.get('x-sandbox-pid') - const headerBoot = r.headers.get('x-sandbox-boot') || undefined - if (r.ok) { - const j = (await r.json()) as { - instance?: { id?: string; pid?: number; bootTime?: string } - } - directResults.push({ - ok: true, - sandboxInstance: j.instance?.id ?? headerInstance, - sandboxPid: j.instance?.pid ?? (headerPid ? Number(headerPid) : undefined), - sandboxBoot: j.instance?.bootTime ?? headerBoot, - status: r.status, - hasSandboxInstanceHeader: !!headerInstance, - hasSandboxInstanceBody: !!j.instance?.id, - }) - } else { - directResults.push({ - ok: false, - status: r.status, - sandboxInstance: headerInstance, - sandboxPid: headerPid ? Number(headerPid) : undefined, - sandboxBoot: headerBoot, - hasSandboxInstanceHeader: !!headerInstance, - }) - } - } catch (e) { - directResults.push({ ok: false, err: (e as Error).message }) - } - await new Promise((r) => setTimeout(r, SPACING_MS)) - } - - // 2) 公网 gateway /preview/{port}/ — 预期 502,从 problem body 提取 sandbox_instance - const sandboxEnvId = process.env.TCB_ENV_ID || '' - const publicResults: IdResult[] = [] - for (let i = 0; i < N; i++) { - try { - let url = `https://${sandboxEnvId}.ap-shanghai.app.tcloudbase.com/preview/5173/?cloudbase_session_id=${resolvedSessionId}` - if (sandboxConfig.sandboxMode === 'shared') url += `&scope_id=${taskId}` - if (isCodingMode) url += `&scope_template=coding` - const r = await fetch(url, { signal: AbortSignal.timeout(8_000) }) - const body = await r.text() - const headerInstance = r.headers.get('x-sandbox-instance') || undefined - const headerPid = r.headers.get('x-sandbox-pid') - const headerBoot = r.headers.get('x-sandbox-boot') || undefined - let bodyInstance: string | undefined - let bodyPid: number | undefined - let bodyBoot: string | undefined - let detail: string | undefined - try { - const parsed = JSON.parse(body) as { - sandbox_instance?: string - sandbox_pid?: number - sandbox_boot?: string - detail?: string - } - bodyInstance = parsed.sandbox_instance - bodyPid = parsed.sandbox_pid - bodyBoot = parsed.sandbox_boot - detail = parsed.detail - } catch { - /* non-json body */ - } - publicResults.push({ - ok: r.ok, - status: r.status, - sandboxInstance: bodyInstance ?? headerInstance, - sandboxPid: bodyPid ?? (headerPid ? Number(headerPid) : undefined), - sandboxBoot: bodyBoot ?? headerBoot, - requestId: r.headers.get('x-cloudbase-request-id') || undefined, - detail, - hasSandboxInstanceHeader: !!headerInstance, - hasSandboxInstanceBody: !!bodyInstance, - }) - } catch (e) { - publicResults.push({ ok: false, status: 0, err: (e as Error).message }) - } - await new Promise((r) => setTimeout(r, SPACING_MS)) - } - - const uniqueIds = (arr: IdResult[]) => - Array.from(new Set(arr.map((r) => r.sandboxInstance).filter((x): x is string => !!x))) - - const directIds = uniqueIds(directResults) - const publicIds = uniqueIds(publicResults) - const overlap = directIds.filter((id) => publicIds.includes(id)) - - let conclusion: string - if (directIds.length === 0 && publicIds.length === 0) { - conclusion = - 'insufficient_data: neither side returned sandbox container id — sandbox image likely needs rebuild to include X-Sandbox-Instance headers' - } else if (directIds.length === 0 || publicIds.length === 0) { - conclusion = `insufficient_data: only one side returned container id (direct=${directIds.length}, public=${publicIds.length})` - } else if (overlap.length === 0) { - conclusion = 'cross_container_confirmed: SCF direct and public gateway land on disjoint sandbox containers' - } else if (directIds.length === 1 && publicIds.length === 1 && overlap.length === 1) { - conclusion = 'same_container: both paths hit the same container — 502 cause is NOT cross-container routing' - } else { - conclusion = 'partial_overlap: mixed routing — some requests share containers, some do not' - } + const sandbox = await getTaskSandbox(task, envId, { isCodingMode }) + if (!sandbox) return c.json({ status: 'no_sandbox' }) - return c.json({ - resolvedSessionId, - scopeId: taskId, - summary: { - directUniqueContainers: directIds, - publicUniqueContainers: publicIds, - overlap, - conclusion, - }, - directResults, - publicResults, + const res = await sandbox.request('/preview/ports', { + signal: AbortSignal.timeout(10_000), }) + if (!res.ok) { + return c.json({ status: 'error', message: `preview/ports returned ${res.status}` }) + } + const info = (await res.json()) as { ports?: Array<{ port: number }> } + const ports = Array.isArray(info.ports) ? info.ports.map((p) => p.port) : [] + const vitePort = ports.includes(STATEFUL_DEFAULT_VITE_PORT) ? STATEFUL_DEFAULT_VITE_PORT : (ports[0] ?? null) + const alive = vitePort !== null + return c.json({ status: alive ? 'running' : 'stopped', vitePort }) } catch (error) { - return c.json({ error: (error as Error).message }, 500) + return c.json({ status: 'error', message: (error as Error).message }) } }) -tasksRouter.get('/:taskId/preview-health', requireUserEnv, async (c) => { +// --------------------------------------------------------------------------- +// GET /:taskId/terminal-health — ttyd via TRW virtual port /preview/7681/ +// --------------------------------------------------------------------------- + +tasksRouter.get('/:taskId/terminal-health', requireUserEnv, async (c) => { try { const session = c.get('session')! const { envId } = c.get('userEnv')! @@ -2552,40 +2597,20 @@ tasksRouter.get('/:taskId/preview-health', requireUserEnv, async (c) => { if (!task) return c.json({ status: 'not_found' }) if (!task.sandboxId) return c.json({ status: 'no_sandbox' }) - const taskMode = (task as any).mode as string | null | undefined + const taskMode = (task as { mode?: string | null }).mode const isCodingMode = taskMode === 'coding' - const sandboxConfig = resolveSandboxConfig({ - sandboxMode: task.sandboxMode, - sandboxSessionId: task.sandboxSessionId, - sandboxCwd: task.sandboxCwd, - envId, - taskId, - }) - const sandbox = await getScfSandbox(task, envId, { - sandboxMode: sandboxConfig.sandboxMode, - isCodingMode, - }) + const sandbox = await getTaskSandbox(task, envId, { isCodingMode }) if (!sandbox) return c.json({ status: 'no_sandbox' }) - // Query scope/info — scope headers are injected automatically by sandbox.request() - const res = await sandbox.request('/api/scope/info', { - signal: AbortSignal.timeout(10_000), + const { resolveTtydPreviewPort } = await import('../sandbox/ttyd-preview.js') + const resolved = await resolveTtydPreviewPort(sandbox) + return c.json({ + status: resolved.status, + port: resolved.port, + retryable: resolved.retryable, }) - if (!res.ok) { - return c.json({ status: 'error', message: `scope/info returned ${res.status}` }) - } - const info = (await res.json()) as { - success?: boolean - workspace?: string - vitePort?: number | null - } - if (!info.success) { - return c.json({ status: 'error', message: 'scope/info not successful' }) - } - const alive = !!info.vitePort - return c.json({ status: alive ? 'running' : 'stopped', vitePort: info.vitePort }) } catch (error) { - return c.json({ status: 'error', message: (error as Error).message }) + return c.json({ status: 'error', message: (error as Error).message, retryable: true }) } }) @@ -2604,9 +2629,9 @@ tasksRouter.post('/:taskId/start-sandbox', requireUserEnv, async (c) => { const logger = createTaskLogger(taskId) if (task.sandboxId) { try { - const existingSandbox = await getScfSandbox(task, envId) + const existingSandbox = await getTaskSandbox(task, envId) if (existingSandbox) { - const testResult = await runCommandInScfSandbox(existingSandbox, 'echo test') + const testResult = await runCommandInSandbox(existingSandbox, 'echo test') if (testResult.success) return c.json({ error: 'Sandbox is already running' }, 400) } } catch { @@ -2615,10 +2640,19 @@ tasksRouter.post('/:taskId/start-sandbox', requireUserEnv, async (c) => { } } await logger.info('Starting sandbox') - const sandbox = await scfSandboxManager.getOrCreate(taskId, envId) - await getDb().tasks.update(taskId, { sandboxId: sandbox.functionName, updatedAt: Date.now() }) - await logger.info('Sandbox started successfully') - return c.json({ success: true, message: 'Sandbox started successfully', sandboxId: sandbox.functionName }) + const provider = getSandboxProvider() + const sandbox = await provider.acquire( + buildStatefulAcquireContext({ + envId, + taskId, + userId: task.userId, + sandboxMode: task.sandboxMode, + sandboxId: null, + }), + ) + await getDb().tasks.update(taskId, { sandboxId: sandbox.id, updatedAt: Date.now() }) + await logger.success(TASK_LOG.PLATFORM_SANDBOX_STARTED) + return c.json({ success: true, message: 'Sandbox started successfully', sandboxId: sandbox.id }) } catch (error) { console.error('Error starting sandbox:', error) return c.json({ error: 'Failed to start sandbox' }, 500) @@ -2628,16 +2662,20 @@ tasksRouter.post('/:taskId/start-sandbox', requireUserEnv, async (c) => { // --------------------------------------------------------------------------- // POST /:taskId/stop-sandbox // --------------------------------------------------------------------------- -tasksRouter.post('/:taskId/stop-sandbox', async (c) => { +tasksRouter.post('/:taskId/stop-sandbox', requireUserEnv, async (c) => { try { - const authErr = requireAuth(c) - if (authErr) return authErr const session = c.get('session')! + const { envId } = c.get('userEnv')! const { taskId } = c.req.param() const task = await getDb().tasks.findById(taskId) if (!task) return c.json({ error: 'Task not found' }, 404) if (task.userId !== session.user.id) return c.json({ error: 'Unauthorized' }, 403) if (!task.sandboxId) return c.json({ error: 'Sandbox is not active' }, 400) + const sandbox = await getTaskSandbox(task, envId).catch(() => null) + if (sandbox && task.sandboxMode === 'isolated') { + const provider = getSandboxProvider() + if (provider.destroy) await provider.destroy(sandbox) + } await getDb().tasks.update(taskId, { sandboxId: null, sandboxUrl: null, updatedAt: Date.now() }) return c.json({ success: true, message: 'Sandbox stopped successfully' }) } catch (error) { @@ -2658,7 +2696,7 @@ tasksRouter.post('/:taskId/restart-dev', requireUserEnv, async (c) => { if (!task) return c.json({ error: 'Task not found' }, 404) if (task.userId !== session.user.id) return c.json({ error: 'Unauthorized' }, 403) if (!task.sandboxId) return c.json({ error: 'Sandbox is not active' }, 400) - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (!sandbox) return c.json({ error: 'Sandbox not available' }, 400) const packageJsonFile = await readFileFromSandbox(sandbox, 'package.json') @@ -2677,11 +2715,11 @@ tasksRouter.post('/:taskId/restart-dev', requireUserEnv, async (c) => { const hasVite = packageJson?.dependencies?.vite || packageJson?.devDependencies?.vite const devPort = hasVite ? 5173 : 3000 - await runCommandInScfSandbox(sandbox, `lsof -ti:${devPort} | xargs -r kill -9 2>/dev/null || true`) + await runCommandInSandbox(sandbox, `lsof -ti:${devPort} | xargs -r kill -9 2>/dev/null || true`) const packageManager = await detectPackageManager(sandbox) const devCommand = packageManager === 'npm' ? 'npm run dev' : `${packageManager} dev` - await runCommandInScfSandbox(sandbox, `nohup ${devCommand} > /dev/null 2>&1 &`) + await runCommandInSandbox(sandbox, `nohup ${devCommand} > /dev/null 2>&1 &`) return c.json({ success: true, message: 'Dev server restarted successfully' }) } catch (error) { @@ -2690,6 +2728,31 @@ tasksRouter.post('/:taskId/restart-dev', requireUserEnv, async (c) => { } }) +// --------------------------------------------------------------------------- +// POST /:taskId/append-log — whitelisted static messages only (Logs pane) +// --------------------------------------------------------------------------- +tasksRouter.post('/:taskId/append-log', requireUserEnv, async (c) => { + try { + const session = c.get('session')! + const { taskId } = c.req.param() + const body = (await c.req.json()) as { type?: LogEntry['type']; message?: string } + const message = typeof body.message === 'string' ? body.message.trim() : '' + if (!message) return c.json({ success: false, error: 'Message required' }, 400) + if (!isAllowedTaskLogMessage(message)) { + return c.json({ success: false, error: 'Message not allowed' }, 400) + } + const task = await findActiveTask(taskId, session.user.id) + if (!task) return c.json({ success: false, error: 'Task not found' }, 404) + + const level = body.type === 'error' || body.type === 'success' || body.type === 'command' ? body.type : 'info' + await appendAllowedTaskLog(taskId, level, message) + return c.json({ success: true }) + } catch (error) { + console.error('Error appending task log:', error) + return c.json({ success: false, error: 'Failed to append log' }, 500) + } +}) + // --------------------------------------------------------------------------- // POST /:taskId/clear-logs // --------------------------------------------------------------------------- @@ -2768,18 +2831,18 @@ tasksRouter.post('/:taskId/file-operation', requireUserEnv, async (c) => { const task = await findActiveTask(taskId, session.user.id) if (!task) return c.json({ success: false, error: 'Task not found' }, 404) if (!task.sandboxId) return c.json({ success: false, error: 'Sandbox not available' }, 400) - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (!sandbox) return c.json({ success: false, error: 'Sandbox not found' }, 404) const sourceBasename = sourceFile.split('/').pop() const targetFile = targetPath ? `${targetPath}/${sourceBasename}` : sourceBasename const escapedSource = sourceFile.replace(/'/g, "'\\''") const escapedTarget = targetFile.replace(/'/g, "'\\''") if (operation === 'copy') { - const copyResult = await runCommandInScfSandbox(sandbox, `cp -r '${escapedSource}' '${escapedTarget}'`) + const copyResult = await runCommandInSandbox(sandbox, `cp -r '${escapedSource}' '${escapedTarget}'`) if (!copyResult.success) return c.json({ success: false, error: 'Failed to copy file' }, 500) return c.json({ success: true, message: 'File copied successfully' }) } else if (operation === 'cut') { - const mvResult = await runCommandInScfSandbox(sandbox, `mv '${escapedSource}' '${escapedTarget}'`) + const mvResult = await runCommandInSandbox(sandbox, `mv '${escapedSource}' '${escapedTarget}'`) if (!mvResult.success) return c.json({ success: false, error: 'Failed to move file' }, 500) return c.json({ success: true, message: 'File moved successfully' }) } else return c.json({ success: false, error: 'Invalid operation' }, 400) @@ -2817,160 +2880,117 @@ tasksRouter.get('/:taskId/preview-url', requireUserEnv, async (c) => { } try { - // ── 获取沙箱 ─────────────────────────────────────────────────────── const sandboxConfig = resolveSandboxConfig({ - sandboxMode: task.sandboxMode, - sandboxSessionId: task.sandboxSessionId, sandboxCwd: task.sandboxCwd, + sandboxMode: task.sandboxMode, envId, taskId, }) let sandbox: SandboxInstance | null = null - let resolvedSessionId = sandboxConfig.sandboxSessionId - let resolvedCwd = sandboxConfig.sandboxCwd - + const resolvedCwd = sandboxConfig.sandboxCwd const taskMode = (task as any).mode as string | null | undefined const isCodingMode = taskMode === 'coding' - const scopeOpts = { sandboxMode: sandboxConfig.sandboxMode, isCodingMode } + const provider = getSandboxProvider() + const { credentials: userCredentials } = c.get('userEnv')! if (task.sandboxId) { - sandbox = await getScfSandbox(task, envId, scopeOpts) + sandbox = await getTaskSandbox(task, envId, { isCodingMode }) } if (!sandbox) { await emit('progress', '正在启动沙箱...') try { - sandbox = await scfSandboxManager.getOrCreate(taskId, envId, { - mode: 'shared', - workspaceIsolation: sandboxConfig.sandboxMode, - sandboxSessionId: sandboxConfig.sandboxSessionId, - isCodingMode, - }) - - await getDb().tasks.update(taskId, { - sandboxId: sandbox.functionName, - sandboxSessionId: resolvedSessionId, - sandboxCwd: resolvedCwd, - updatedAt: Date.now(), - }) + sandbox = await getTaskSandbox(task, envId, { isCodingMode, allowCreate: true }) + if (sandbox) { + await getDb().tasks.update(taskId, { + sandboxId: sandbox.id, + sandboxCwd: STATEFUL_WORKSPACE_ROOT, + sandboxMode: sandboxConfig.sandboxMode, + updatedAt: Date.now(), + }) + } } catch (err) { await emit('error', `沙箱启动失败: ${(err as Error).message}`) return } } - // ── coding mode: 确保 workspace + vite 就绪 ────────────────────── - // /api/scope/info with X-Scope-Template: coding triggers initialization on first call: - // - seedCodingTemplate (first time) or git restore (warm restart) - // - ensureViteDev: npm install + spawn vite + crash auto-restart - // We poll scope/info until vitePort is returned AND viteReady === true - // (避免 vite 已 spawn 但端口未 bind 导致网关 502 ECONNREFUSED) + if (!sandbox) { + await emit('error', '沙箱启动失败') + return + } + + try { + const prepared = await provider.prepare(sandbox, { + credentials: { + envId, + secretId: userCredentials?.secretId || '', + secretKey: userCredentials?.secretKey || '', + sessionToken: userCredentials?.sessionToken || undefined, + }, + workspaceHint: STATEFUL_WORKSPACE_ROOT, + codingMode: isCodingMode, + backendOptions: { backend: 'stateful' }, + }) + const workspace = prepared.workspace || STATEFUL_WORKSPACE_ROOT + await getDb().tasks.update(taskId, { + sandboxCwd: workspace, + sandboxMode: sandboxConfig.sandboxMode, + updatedAt: Date.now(), + }) + } catch (err) { + await emit('error', `工作空间初始化失败: ${(err as Error).message}`) + return + } + if (isCodingMode) { - // 轻量凭证注入:用 PUT /api/session/env 而不是 POST /api/session/init。 - // session/init 会额外触发 ensureWorkspace → ensureViteDev,与下面 scope/info - // 的初始化路径并发竞争 allocatePort(5173) + spawnVite,可能导致: - // - 第二个 spawn 因 --strictPort 失败 - // - 计入 crash 计数并走 1-3s 指数退避 - // session/env 只写 .session-config.json / secrets,无副作用。 try { - const { credentials: userCredentials } = c.get('userEnv')! await emit('progress', '正在初始化工作空间...') - sandbox! - .request('/api/session/env', { + const envPayload = { + CLOUDBASE_ENV_ID: envId, + ...(userCredentials?.secretId ? { TENCENTCLOUD_SECRETID: userCredentials.secretId } : {}), + ...(userCredentials?.secretKey ? { TENCENTCLOUD_SECRETKEY: userCredentials.secretKey } : {}), + ...(userCredentials?.sessionToken ? { TENCENTCLOUD_SESSIONTOKEN: userCredentials.sessionToken } : {}), + } + await sandbox + .request('/api/workspace/env', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - CLOUDBASE_ENV_ID: envId, - ...(userCredentials?.secretId ? { TENCENTCLOUD_SECRETID: userCredentials.secretId } : {}), - ...(userCredentials?.secretKey ? { TENCENTCLOUD_SECRETKEY: userCredentials.secretKey } : {}), - ...(userCredentials?.sessionToken ? { TENCENTCLOUD_SESSIONTOKEN: userCredentials.sessionToken } : {}), - }), + body: JSON.stringify(envPayload), signal: AbortSignal.timeout(30_000), }) .catch((err: Error) => { - console.warn('[preview-url] session/env failed:', err.message) + console.warn('[preview-url] workspace env failed:', err.message) }) } catch (err) { - console.warn('[preview-url] session/env setup failed:', (err as Error).message) - } - } - - // ── Poll /api/scope/info for vitePort + viteReady ───────────────────── - await emit('progress', '正在等待开发服务器就绪...') - const maxWaitMs = 120_000 - const pollInterval = 2000 - const startTime = Date.now() - let port: number | null = null - - while (Date.now() - startTime < maxWaitMs) { - try { - const infoRes = await sandbox!.request('/api/scope/info', { - signal: AbortSignal.timeout(30_000), - }) - if (infoRes.ok) { - const info = (await infoRes.json()) as { - success?: boolean - workspace?: string - vitePort?: number | null - viteState?: 'stopped' | 'starting' | 'running' | 'restarting' | 'failed' | null - viteReady?: boolean - viteFailureReason?: string - } - // 沙箱声明为 failed 的场景是不可自动恢复的(比如 npm install 因 ENOTEMPTY - // 失败 → 状态 failed → 下一次 ensureViteDev 重建仍然撞上同一个脏状态)。 - // 此时继续轮询只会等到 120s 超时,不如立刻把错误抛给用户并让前端停止轮询。 - if (info.viteState === 'failed') { - const reason = info.viteFailureReason || '开发服务器启动失败(未知原因)' - await emit('error', `开发服务器启动失败:${reason}`, { - viteState: 'failed', - viteFailureReason: info.viteFailureReason, - }) - return - } - // 就绪判断:沙箱侧 viteReady=true 即 vite 已 bind 端口、可接请求。 - // 早期我们还要求对公网 gateway 做 HEAD 二次探测以防跨 container,但 - // 实测(container-probe)证明 `.ap-shanghai.app.tcloudbase.com/preview/` 与 - // SCF 直连命中同一个 warm container(sandbox_instance 一致),所以 - // 去掉这次额外探测以减少 QPS 压力(避免 CloudBase 430 限流)。 - if (info.success && info.vitePort) { - const sandboxReady = info.viteReady === undefined ? true : info.viteReady - if (sandboxReady) { - port = info.vitePort - break - } - } - } - } catch { - // scope/info not available yet + console.warn('[preview-url] workspace/env setup failed:', (err as Error).message) } - await new Promise((r) => setTimeout(r, pollInterval)) } - if (!port) { - await emit('error', `Dev server 未能在 ${maxWaitMs / 1000}s 内就绪`) + await emit('progress', '正在等待 沙箱业务镜像 Vite 开发服务器就绪...') + const viteReady = await waitForSandboxViteReady(sandbox, { + port: STATEFUL_DEFAULT_VITE_PORT, + maxWaitMs: 120_000, + onProgress: async ({ message }) => { + await emit('progress', message) + }, + }) + const port = viteReady.port + if (!isViteReadyResult(viteReady)) { + await emit('error', viteReady.message ?? '开发服务器未就绪') return } // ── 获取网关 URL ────────────────────────────────────────────────── let previewBase: string try { - previewBase = await scfSandboxManager.ensurePreviewGateway(sandbox!) + previewBase = await getSandboxProvider().getPreviewBaseUrl(sandbox!) } catch { - const sandboxEnvId = process.env.TCB_ENV_ID || '' - previewBase = `https://${sandboxEnvId}.ap-shanghai.app.tcloudbase.com/preview` + previewBase = `${sandbox!.baseUrl}/preview` } - // Build gateway URL with scope query params - // - cloudbase_session_id: routes to the correct SCF session - // - scope_id: only in shared mode, routes to the correct sub-workspace - // - scope_template: signals coding template (vite dev server) - let gatewayUrl = `${previewBase}/${port}/?cloudbase_session_id=${resolvedSessionId}` - if (sandboxConfig.sandboxMode === 'shared') { - gatewayUrl += `&scope_id=${taskId}` - } - if (isCodingMode) { - gatewayUrl += `&scope_template=coding` - } + // Stateful: browser cannot attach gateway auth headers — 经 OpenVibeCoding 反代. + const gatewayUrl = `/api/tasks/${taskId}/preview/${port}/` await emit('ready', 'Dev server ready', { gatewayUrl, port }) } catch (err) { // 顶层异常兜底:确保前端总能收到 error 事件而非静默关闭 @@ -3003,48 +3023,37 @@ tasksRouter.get('/:taskId/preview-errors', requireUserEnv, async (c) => { const { envId } = c.get('userEnv')! const taskMode = (task as any).mode as string | null | undefined const isCodingMode = taskMode === 'coding' - const sandboxConfig = resolveSandboxConfig({ - sandboxMode: task.sandboxMode, - sandboxSessionId: task.sandboxSessionId, - sandboxCwd: task.sandboxCwd, - envId, - taskId, - }) - const sandbox = await getScfSandbox(task, envId, { - sandboxMode: sandboxConfig.sandboxMode, - isCodingMode, - }) + const sandbox = await getTaskSandbox(task, envId, { isCodingMode }) if (!sandbox) { return c.json({ ok: true, buildErrors: [], runtimeErrors: [] }) } - // 1) 查 vitePort —— 没起 vite 等价于没错误 + // 1) 沙箱业务镜像 GET /preview/ports — no vite → no errors let vitePort: number | null = null try { - const infoRes = await sandbox.request('/api/scope/info', { + const portsRes = await sandbox.request('/preview/ports', { signal: AbortSignal.timeout(4_000), }) - if (infoRes.ok) { - const info = (await infoRes.json()) as { success?: boolean; vitePort?: number | null } - if (info.success && info.vitePort) vitePort = info.vitePort + if (portsRes.ok) { + const info = (await portsRes.json()) as { ports?: Array<{ port: number }> } + const ports = Array.isArray(info.ports) ? info.ports.map((p) => p.port) : [] + vitePort = ports.includes(STATEFUL_DEFAULT_VITE_PORT) ? STATEFUL_DEFAULT_VITE_PORT : (ports[0] ?? null) } } catch { - // 超时 / scope/info 不可用 → 视为没 vite + // preview/ports unavailable → treat as no vite } if (!vitePort) { return c.json({ ok: true, buildErrors: [], runtimeErrors: [] }) } - // 2) 构造公网 gateway URL(与 iframe 同路径),拉 __dev_errors - const sandboxEnvId = process.env.TCB_ENV_ID || '' - const previewBase = `https://${sandboxEnvId}.ap-shanghai.app.tcloudbase.com/preview` - const resolvedSessionId = sandboxConfig.sandboxSessionId - let devErrorsUrl = `${previewBase}/${vitePort}/__dev_errors?cloudbase_session_id=${resolvedSessionId}` - if (sandboxConfig.sandboxMode === 'shared') devErrorsUrl += `&scope_id=${taskId}` - if (isCodingMode) devErrorsUrl += `&scope_template=coding` - - const res = await fetch(devErrorsUrl, { signal: AbortSignal.timeout(8_000) }) + // 2) Same path as preview iframe: 沙箱业务镜像 /preview/{port}/__dev_errors via gateway auth + const authHeaders = await sandbox.getAuthHeaders() + const devErrorsUrl = `${sandbox.baseUrl}/preview/${vitePort}/__dev_errors` + const res = await fetch(devErrorsUrl, { + headers: authHeaders, + signal: AbortSignal.timeout(8_000), + }) if (!res.ok) { return c.json({ error: `dev server __dev_errors returned ${res.status}` }, 500 as const) } @@ -3085,13 +3094,13 @@ tasksRouter.get('/:taskId/files/download', requireUserEnv, async (c) => { if (!task) return c.json({ error: 'Task not found' }, 404) if (!task.sandboxId) return c.json({ error: 'Sandbox is not running' }, 410) - const sandbox = await getScfSandbox(task, envId) + const sandbox = await getTaskSandbox(task, envId) if (!sandbox) return c.json({ error: 'Sandbox not found' }, 410) const basename = filePath === '.' ? 'workspace' : filePath.split('/').pop() || 'download' // Check if directory - const statResult = await runCommandInScfSandbox( + const statResult = await runCommandInSandbox( sandbox, `test -d '${filePath.replace(/'/g, "'\\''")}' && echo dir || echo file`, ) @@ -3117,14 +3126,14 @@ tasksRouter.get('/:taskId/files/download', requireUserEnv, async (c) => { js: 'text/javascript', ts: 'text/typescript', } - const fileResp = await sandbox.request(`/e2b-compatible/files?path=${encodeURIComponent(filePath)}`) - if (!fileResp.ok) return c.json({ error: 'File not found' }, 404) - const buffer = await fileResp.arrayBuffer() - return new Response(buffer, { + const bytes = await downloadFileFromSandbox(sandbox, filePath) + if (!bytes) return c.json({ error: 'File not found' }, 404) + const fileBuf = Buffer.from(bytes) + return new Response(fileBuf, { headers: { 'Content-Type': mimeMap[ext] || 'application/octet-stream', 'Content-Disposition': `attachment; filename="${basename}"`, - 'Content-Length': String(buffer.byteLength), + 'Content-Length': String(fileBuf.byteLength), }, }) } @@ -3132,27 +3141,28 @@ tasksRouter.get('/:taskId/files/download', requireUserEnv, async (c) => { // Directory: zip in sandbox → fetch the zip file → stream to browser const tmpZip = `.tmp/__dl_${Date.now()}.zip` const quoted = filePath.replace(/'/g, "'\\''") - const cleanup = () => runCommandInScfSandbox(sandbox!, `rm -f '${tmpZip}'`).catch(() => {}) + const cleanup = () => runCommandInSandbox(sandbox!, `rm -f '${tmpZip}'`).catch(() => {}) try { - const zipResult = await runCommandInScfSandbox( + // Run from workspace root: zip -r .tmp/archive.zip (do not cd into dir — relative .tmp/ would land inside it). + const zipResult = await runCommandInSandbox( sandbox, - `mkdir -p .tmp && cd '${quoted}' && zip -r '${tmpZip}' . && echo ok`, - 60000, + `mkdir -p .tmp && zip -qr '${tmpZip.replace(/'/g, "'\\''")}' '${quoted}'`, + 120000, ) - if (!zipResult.success || zipResult.output?.trim() !== 'ok') { + if (!zipResult.success) { return c.json({ error: 'Failed to create zip in sandbox' }, 500) } - const zipResp = await sandbox.request(`/e2b-compatible/files?path=${encodeURIComponent(tmpZip)}`) - if (!zipResp.ok) return c.json({ error: 'Failed to fetch zip from sandbox' }, 500) + const zipBytes = await downloadFileFromSandbox(sandbox, tmpZip) + if (!zipBytes) return c.json({ error: 'Failed to fetch zip from sandbox' }, 500) - const buffer = await zipResp.arrayBuffer() - return new Response(buffer, { + const zipBuf = Buffer.from(zipBytes) + return new Response(zipBuf, { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${basename}.zip"`, - 'Content-Length': String(buffer.byteLength), + 'Content-Length': String(zipBuf.byteLength), }, }) } finally { @@ -3217,4 +3227,34 @@ tasksRouter.post('/:taskId/git/disassociate', async (c) => { return c.json({ success: true, message: 'Repository disassociated successfully' }) }) +// --------------------------------------------------------------------------- +// Preview proxy — browser iframe cannot send X-Cloudbase-Authorization / E2b-* headers +// --------------------------------------------------------------------------- + +const PREVIEW_PROXY_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] as const + +async function handleTaskPreviewProxy(c: Context) { + const session = c.get('session')! + const { envId } = c.get('userEnv')! + const { taskId, port } = c.req.param() + const task = await findActiveTask(taskId, session.user.id) + if (!task) return c.json({ error: 'Task not found' }, 404) + if (!task.sandboxId) return c.json({ error: 'Sandbox is not running' }, 410) + + const taskMode = (task as { mode?: string | null }).mode + const sandbox = await getTaskSandbox(task, envId, { isCodingMode: taskMode === 'coding' }) + if (!sandbox) return c.json({ error: 'Sandbox not available' }, 410) + + const prefix = `/api/tasks/${taskId}/preview/${port}` + let subpath = c.req.path.startsWith(prefix) ? c.req.path.slice(prefix.length) : '/' + if (!subpath || subpath === '') subpath = '/' + + return proxyTaskPreview(c, sandbox, taskId, port, subpath) +} + +for (const method of PREVIEW_PROXY_METHODS) { + tasksRouter.on(method, '/:taskId/preview/:port', requireUserEnv, handleTaskPreviewProxy) + tasksRouter.on(method, '/:taskId/preview/:port/*', requireUserEnv, handleTaskPreviewProxy) +} + export default tasksRouter diff --git a/packages/server/src/sandbox/__tests__/mcporter-schema-parse.test.ts b/packages/server/src/sandbox/__tests__/mcporter-schema-parse.test.ts new file mode 100644 index 0000000..f5a412e --- /dev/null +++ b/packages/server/src/sandbox/__tests__/mcporter-schema-parse.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { parseMcporterSchemaContent } from '../stateful/stateful-mcp-client.js' + +describe('parseMcporterSchemaContent', () => { + it('parses clean JSON object', () => { + const { tools } = parseMcporterSchemaContent('{"tools":[{"name":"envQuery"}]}') + expect(tools).toHaveLength(1) + }) + + it('skips stderr prefix before JSON', () => { + const { tools } = parseMcporterSchemaContent('warning: foo\n{"tools":[{"name":"a"}]}') + expect(tools).toHaveLength(1) + }) + + it('accepts top-level array', () => { + const { tools } = parseMcporterSchemaContent('[{"name":"a"}]') + expect(tools).toHaveLength(1) + }) +}) diff --git a/packages/server/src/sandbox/__tests__/preview-proxy.test.ts b/packages/server/src/sandbox/__tests__/preview-proxy.test.ts new file mode 100644 index 0000000..7fec371 --- /dev/null +++ b/packages/server/src/sandbox/__tests__/preview-proxy.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { rewritePreviewPaths, shouldRewritePreviewBody } from '../preview-proxy.js' + +describe('rewritePreviewPaths', () => { + it('rewrites vite base asset URLs to task preview proxy', () => { + const html = + '' + '' + const out = rewritePreviewPaths(html, 'task-1', '5173') + expect(out).toContain('/api/tasks/task-1/preview/5173/@vite/client') + expect(out).not.toContain('"/preview/5173/') + }) + + it('does not rewrite ttyd virtual port HTML', () => { + expect(shouldRewritePreviewBody('text/html', '7681')).toBe(false) + expect(shouldRewritePreviewBody('text/html', '5173')).toBe(true) + }) +}) diff --git a/packages/server/src/sandbox/__tests__/preview-ws-proxy.test.ts b/packages/server/src/sandbox/__tests__/preview-ws-proxy.test.ts new file mode 100644 index 0000000..3ca82de --- /dev/null +++ b/packages/server/src/sandbox/__tests__/preview-ws-proxy.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { buildGatewayWebSocketUrl, buildUpstreamGatewayWsHeaders, parseClientWsProtocols } from '../preview-ws-proxy.js' + +describe('buildGatewayWebSocketUrl', () => { + it('includes gateway path prefix before /preview/{port}/ws', () => { + const url = buildGatewayWebSocketUrl('https://env.api.tcloudbasegateway.com/v1/sandbox/-', '/preview/29100/ws') + expect(url).toBe('wss://env.api.tcloudbasegateway.com/v1/sandbox/-/preview/29100/ws') + }) + + it('preserves query string', () => { + const url = buildGatewayWebSocketUrl( + 'https://env.api.tcloudbasegateway.com/v1/sandbox/-/', + '/preview/5173/ws', + '?token=abc', + ) + expect(url).toBe('wss://env.api.tcloudbasegateway.com/v1/sandbox/-/preview/5173/ws?token=abc') + }) +}) + +describe('buildUpstreamGatewayWsHeaders', () => { + it('passes only sandbox auth headers (no browser Origin)', () => { + const headers = buildUpstreamGatewayWsHeaders({ + 'X-Cloudbase-Authorization': 'Bearer key', + 'E2b-Sandbox-Id': 'sbx', + 'E2b-Sandbox-Port': '9000', + }) + expect(headers).toEqual({ + 'X-Cloudbase-Authorization': 'Bearer key', + 'E2b-Sandbox-Id': 'sbx', + 'E2b-Sandbox-Port': '9000', + }) + expect(headers.origin).toBeUndefined() + }) +}) + +describe('parseClientWsProtocols', () => { + it('parses tty subprotocol from Sec-WebSocket-Protocol', () => { + const protocols = parseClientWsProtocols({ + headers: { 'sec-websocket-protocol': 'tty' }, + } as import('node:http').IncomingMessage) + expect(protocols).toEqual(['tty']) + }) + + it('returns undefined when header missing', () => { + expect(parseClientWsProtocols({ headers: {} } as import('node:http').IncomingMessage)).toBeUndefined() + }) +}) diff --git a/packages/server/src/sandbox/__tests__/ttyd-gateway-port.test.ts b/packages/server/src/sandbox/__tests__/ttyd-gateway-port.test.ts new file mode 100644 index 0000000..3c387f9 --- /dev/null +++ b/packages/server/src/sandbox/__tests__/ttyd-gateway-port.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { parseTtydBackendPortFromCmdline } from '../ttyd-gateway-port.js' + +describe('parseTtydBackendPortFromCmdline', () => { + it('parses ttyd backend port from pgrep line', () => { + expect(parseTtydBackendPortFromCmdline('442 ttyd -W -p 29100 bash -l')).toBe(29100) + }) + + it('returns null for out-of-range port', () => { + expect(parseTtydBackendPortFromCmdline('ttyd -W -p 7681 bash')).toBeNull() + }) + + it('returns null when ttyd missing', () => { + expect(parseTtydBackendPortFromCmdline('')).toBeNull() + }) +}) diff --git a/packages/server/src/sandbox/acquire-context.ts b/packages/server/src/sandbox/acquire-context.ts new file mode 100644 index 0000000..127d6f0 --- /dev/null +++ b/packages/server/src/sandbox/acquire-context.ts @@ -0,0 +1,26 @@ +/** + * Build StatefulSandboxProvider acquire context from a task record. + */ + +import { normalizeSandboxMode, type SandboxInstanceMode } from '../lib/sandbox-config.js' +import type { AcquireContext } from './provider/types.js' + +export function buildStatefulAcquireContext(args: { + envId: string + taskId: string + userId: string + sandboxMode?: string | null + sandboxId?: string | null +}): AcquireContext { + const sandboxMode: SandboxInstanceMode = normalizeSandboxMode(args.sandboxMode) + return { + envId: args.envId, + conversationId: args.taskId, + backendOptions: { backend: 'stateful', sandboxMode }, + meta: { + userId: args.userId, + sandboxMode, + preferredSandboxId: args.sandboxId ?? null, + }, + } +} diff --git a/packages/server/src/sandbox/ags-error.ts b/packages/server/src/sandbox/ags-error.ts new file mode 100644 index 0000000..66474d5 --- /dev/null +++ b/packages/server/src/sandbox/ags-error.ts @@ -0,0 +1,95 @@ +/** + * Normalize @cloudbase/manager-node (CloudBaseError) for logs and user-facing text. + * + * AGS/Tencent API puts RequestId on the error object, NOT inside Error.message. + * message is typically `[Action] ${apiMessage}` — using only .message drops requestId. + */ + +type ManagerErr = Error & { + code?: string + action?: string + requestId?: string + data?: unknown + cause?: unknown + original?: { RequestId?: string; Code?: string; Message?: string; Error?: { Code?: string; Message?: string } } +} + +/** Unwrap Error.cause chains from stateful-provider rethrows. */ +export function unwrapManagerApiError(err: unknown): ManagerErr { + let cur = err as ManagerErr + for (let i = 0; i < 4; i++) { + if (pickRequestId(cur) || pickCode(cur) || cur.action) return cur + const next = cur.cause + if (!next || typeof next !== 'object') break + cur = next as ManagerErr + } + return cur +} + +function pickRequestId(e: ManagerErr | undefined): string | undefined { + if (!e) return undefined + return ( + ( + e.requestId || + e.original?.RequestId || + (e.data && typeof e.data === 'object' && e.data !== null + ? String((e.data as { RequestId?: string }).RequestId || '') + : undefined) || + undefined + )?.trim() || undefined + ) +} + +function pickCode(e: ManagerErr | undefined): string | undefined { + if (!e) return undefined + return e.code || e.original?.Code || e.original?.Error?.Code +} + +function pickApiMessage(e: ManagerErr | undefined): string { + if (!e) return 'Unknown error' + const fromOriginal = e.original?.Message || e.original?.Error?.Message + if (fromOriginal) return fromOriginal + const msg = e.message || '' + const action = e.action + if (action && msg.startsWith(`[${action}]`)) { + return msg.slice(`[${action}]`.length).trim() || msg + } + return msg || 'Unknown error' +} + +/** Server logs / engineers — pipe-separated, includes optional context. */ +export function formatAgsManagerError(err: unknown, context?: string): string { + const e = unwrapManagerApiError(err) + const parts: string[] = [] + if (context) parts.push(context) + const action = e.action + const apiMsg = pickApiMessage(e) + parts.push(action ? `[${action}] ${apiMsg}` : apiMsg) + const code = pickCode(e) + const requestId = pickRequestId(e) + if (code) parts.push(`code=${code}`) + if (requestId) parts.push(`requestId=${requestId}`) + return parts.filter(Boolean).join(' | ') || String(err) +} + +/** + * End-user chat copy — RequestId on its own line for support escalation. + */ +export function formatAgsUserFacingError(err: unknown): string { + const e = unwrapManagerApiError(err) + const action = e.action + const apiMsg = pickApiMessage(e) + const code = pickCode(e) + const requestId = pickRequestId(e) + + const lines: string[] = [] + lines.push(action ? `[${action}] ${apiMsg}` : apiMsg) + if (code) lines.push(`错误码:${code}`) + if (requestId) lines.push(`RequestId:${requestId}(报障时请提供此 ID)`) + return lines.join('\n') +} + +export function isAgsRetryableError(err: unknown): boolean { + const msg = (err as Error)?.message || '' + return /internal error has occurred/i.test(msg) || /ResourceInsufficient/i.test(msg) +} diff --git a/packages/server/src/sandbox/ensure-stateful-tool.ts b/packages/server/src/sandbox/ensure-stateful-tool.ts new file mode 100644 index 0000000..a5e66a5 --- /dev/null +++ b/packages/server/src/sandbox/ensure-stateful-tool.ts @@ -0,0 +1,288 @@ +/** + * Ensure a CloudBase sandbox Tool (SDT) exists for the given envId. + * Persists tool id in settings (shared) or user_resources (isolated/task scope). + */ + +import { getDb } from '../db/index.js' +import { getProvisionMode } from '../lib/provision-config.js' +import type { SandboxProgressCallback } from './provider/types.js' +import { waitStatefulToolImageWarmup } from './stateful-tool-warmup.js' +import { + formatMissingStatefulSandboxImageError, + resolveStatefulImageRegistryType, + resolveStatefulSandboxImage, +} from './stateful-vibecoding-image.js' +import { resolveAgsSandboxTimeout } from './stateful-sandbox-ttl.js' +import { agsCredentialsFromProcessEnv, callAgsManagerApi as requestAgsManagerApi } from '../lib/cloudbase-ags-api.js' + +export const STATEFUL_TOOL_SETTINGS_KEY = 'stateful_tool_id' + +const DEFAULT_TOOL_ROLE_ARN = 'qcs::cam::uin/691612481:roleName/agent-sandbox' + +/** Stable AGS ToolName for a CloudBase env (AppId-unique). */ +export function statefulToolNameForEnv(envId: string): string { + const slug = envId.replace(/[^a-zA-Z0-9-]/g, '-').slice(0, 48) + return `openvibecoding-${slug || 'default'}` +} + +function extractSandboxToolSet(resp: Record): Array> { + const set = resp.SandboxToolSet + if (Array.isArray(set)) return set + const nested = (resp.data as Record | undefined)?.SandboxToolSet + return Array.isArray(nested) ? nested : [] +} + +function pickToolIdByName(tools: Array>, toolName: string): string | null { + const matches = tools.filter((t) => t.ToolName === toolName && typeof t.ToolId === 'string') + if (!matches.length) return null + const active = matches.find((t) => t.Status === 'ACTIVE') ?? matches[0] + return active.ToolId as string +} + +/** Resolve existing sdt-xxx by fixed ToolName before CreateSandboxTool. */ +async function findSandboxToolIdByName(toolName: string): Promise { + try { + const filtered = await callAgsManagerApi('DescribeSandboxToolList', { + Filters: [{ Name: 'ToolName', Values: [toolName] }], + Limit: 20, + }) + const hit = pickToolIdByName(extractSandboxToolSet(filtered), toolName) + if (hit) return hit + } catch { + // Filter key may be unsupported on some API versions; fall back to paginated list. + } + + let offset = 0 + const limit = 100 + for (let page = 0; page < 10; page++) { + const resp = await callAgsManagerApi('DescribeSandboxToolList', { Offset: offset, Limit: limit }) + const set = extractSandboxToolSet(resp) + const hit = pickToolIdByName(set, toolName) + if (hit) return hit + const total = typeof resp.TotalCount === 'number' ? resp.TotalCount : 0 + offset += limit + if (set.length < limit || offset >= total) break + } + return null +} + +function resolveSandboxGatewayUrl(envId: string): string { + if (!envId) throw new Error('Missing envId to derive stateful sandbox gateway URL') + return `https://${envId}.api.tcloudbasegateway.com/v1/sandbox/-` +} + +async function callAgsManagerApi(action: string, param: Record): Promise> { + return requestAgsManagerApi(action, param, agsCredentialsFromProcessEnv()) +} + +async function createSandboxTool(envId: string): Promise { + const image = resolveStatefulSandboxImage() + if (!image) { + throw new Error(formatMissingStatefulSandboxImageError()) + } + + const roleArn = process.env.STATEFUL_TOOL_ROLE_ARN || DEFAULT_TOOL_ROLE_ARN + const toolName = statefulToolNameForEnv(envId) + + const data = { + ToolName: toolName, + ToolType: 'custom', + RoleArn: roleArn, + CustomConfiguration: { + Image: image, + ImageRegistryType: resolveStatefulImageRegistryType(image), + Command: JSON.parse(process.env.STATEFUL_TOOL_COMMAND || '["/init"]'), + Resources: { + CPU: process.env.STATEFUL_TOOL_CPU || '2', + Memory: process.env.STATEFUL_TOOL_MEMORY || '2Gi', + }, + // Preview (vite/ttyd) and MCP go through TRW :9000 (/preview/{port}/). Do not declare 5173/7681 here — + // the gateway may treat them as real container ports and break /preview/7681/ virtual routing. + Ports: [ + { Name: 'trw', Protocol: 'TCP', Port: 9000 }, + { Name: 'envd', Protocol: 'TCP', Port: 49983 }, + ], + Probe: { + HttpGet: { Path: '/health', Port: 9000, Scheme: 'HTTP' }, + ReadyTimeoutMs: 25_000, + ProbeTimeoutMs: 5000, + ProbePeriodMs: 3000, + SuccessThreshold: 1, + FailureThreshold: 7, + }, + }, + NetworkConfiguration: { NetworkMode: 'PUBLIC' }, + DefaultTimeout: resolveAgsSandboxTimeout(), + Description: `OpenVibeCoding stateful sandbox for env ${envId}`, + } + + const resp = await callAgsManagerApi('CreateSandboxTool', data) + const toolId = + (resp?.ToolId as string) || ((resp?.data as Record | undefined)?.ToolId as string) || '' + if (!toolId) { + throw new Error(`CreateSandboxTool returned no ToolId: ${JSON.stringify(resp).slice(0, 300)}`) + } + console.log(`[StatefulTool] Created tool ${toolId} (${toolName}) for env ${envId}`) + return toolId +} + +async function readStoredToolId(envId: string, userId?: string, taskId?: string): Promise { + const db = getDb() + const provisionMode = await getProvisionMode() + + if (provisionMode === 'shared') { + const row = await db.settings.findSystemSetting(STATEFUL_TOOL_SETTINGS_KEY) + return row?.value || null + } + + if (userId) { + const resources = await db.userResources.findAllByUserId(userId) + const hit = + resources.find((r) => r.envId === envId && r.statefulToolId) || + (taskId ? resources.find((r) => r.taskId === taskId && r.statefulToolId) : undefined) + if (hit?.statefulToolId) return hit.statefulToolId + } + + return null +} + +/** Tool template CustomConfiguration (Image, Ports, Probe, …). */ +export async function describeStatefulToolCustomConfiguration(toolId: string): Promise | null> { + const resp = await callAgsManagerApi('DescribeSandboxToolList', { ToolIds: [toolId] }) + const tool = extractSandboxToolSet(resp).find((t) => t.ToolId === toolId) ?? extractSandboxToolSet(resp)[0] + if (!tool || typeof tool.ToolId !== 'string') return null + const cfg = tool.CustomConfiguration + return cfg && typeof cfg === 'object' && !Array.isArray(cfg) ? (cfg as Record) : null +} + +/** Compare AGS tool Image with env-resolved URI; UpdateSandboxTool + warmup when drifted. */ +async function reconcileStatefulToolImageIfDrift( + toolId: string, + onProgress?: SandboxProgressCallback, + knownConfig?: Record | null, +): Promise { + const desiredImage = resolveStatefulSandboxImage() + if (!desiredImage) { + throw new Error(formatMissingStatefulSandboxImageError()) + } + + const cfg = knownConfig ?? (await describeStatefulToolCustomConfiguration(toolId)) + if (!cfg) { + throw new Error('Cannot reconcile sandbox tool image: CustomConfiguration missing') + } + const currentImage = typeof cfg.Image === 'string' ? cfg.Image.trim() : '' + if (!currentImage || currentImage === desiredImage) return + + console.log('[StatefulTool] Image drift detected, updating sandbox tool template') + onProgress?.({ + phase: 'template_update', + message: '沙箱模板镜像与配置不一致,正在同步...\n', + }) + + const param = { + ToolId: toolId, + CustomConfiguration: { + ...cfg, + Image: desiredImage, + ImageRegistryType: resolveStatefulImageRegistryType(desiredImage), + }, + } + + let updated = false + for (const action of ['UpdateSandboxTool', 'ModifySandboxTool'] as const) { + try { + await callAgsManagerApi(action, param) + updated = true + break + } catch (err) { + console.warn(`[StatefulTool] ${action} failed:`, (err as Error).message) + } + } + if (!updated) { + throw new Error('UpdateSandboxTool/ModifySandboxTool failed while reconciling sandbox image') + } + + await waitStatefulToolImageWarmup(onProgress) +} + +async function persistToolId(envId: string, toolId: string, userId?: string, taskId?: string): Promise { + const db = getDb() + const provisionMode = await getProvisionMode() + + if (provisionMode === 'shared') { + await db.settings.upsertSystemSetting(STATEFUL_TOOL_SETTINGS_KEY, toolId) + return + } + + if (!userId) return + const resources = await db.userResources.findAllByUserId(userId) + const hit = + resources.find((r) => r.envId === envId) || (taskId ? resources.find((r) => r.taskId === taskId) : undefined) + if (hit) { + await db.userResources.update(hit.id, { statefulToolId: toolId, updatedAt: Date.now() }) + } +} + +/** + * Resolve ToolId for envId: debug override → DB → AGS lookup by ToolName → CreateTool. + * Existing tools: Describe + reconcile Image when STATEFUL_SANDBOX_IMAGE (or default) drifts. + */ +export async function ensureStatefulTool( + envId: string, + opts?: { userId?: string; taskId?: string; onProgress?: SandboxProgressCallback }, +): Promise { + const override = process.env.STATEFUL_TOOL_ID || process.env.STATEFUL_SANDBOX_TOOL_ID || '' + if (override) { + console.warn('[StatefulTool] STATEFUL_TOOL_ID override active (debug only)') + return override + } + + const existing = await readStoredToolId(envId, opts?.userId, opts?.taskId) + if (existing) { + opts?.onProgress?.({ + phase: 'template_resolve', + message: '使用已登记的沙箱模板...\n', + }) + const cfg = await describeStatefulToolCustomConfiguration(existing) + if (cfg) { + await reconcileStatefulToolImageIfDrift(existing, opts?.onProgress, cfg) + return existing + } + console.warn('[StatefulTool] Stored tool id missing in AGS, rebinding by ToolName') + } + + const toolName = statefulToolNameForEnv(envId) + const byName = await findSandboxToolIdByName(toolName) + if (byName) { + console.log('[StatefulTool] Bound existing tool by ToolName') + opts?.onProgress?.({ + phase: 'template_bind', + message: '绑定已有沙箱模板...\n', + }) + await persistToolId(envId, byName, opts?.userId, opts?.taskId) + await reconcileStatefulToolImageIfDrift(byName, opts?.onProgress) + return byName + } + + opts?.onProgress?.({ + phase: 'template_create', + message: '正在创建沙箱模板(本环境仅首次,后续任务直接复用)...\n', + }) + const toolId = await createSandboxTool(envId) + await persistToolId(envId, toolId, opts?.userId, opts?.taskId) + await waitStatefulToolImageWarmup(opts?.onProgress) + return toolId +} + +export function resolveStatefulGatewayUrl(envId: string): string { + return resolveSandboxGatewayUrl(envId) +} + +export async function deleteStatefulToolForEnv(envId: string, toolId: string): Promise { + try { + await callAgsManagerApi('DeleteSandboxTool', { ToolId: toolId }) + console.log(`[StatefulTool] Deleted tool ${toolId} for env ${envId}`) + } catch (err) { + console.warn(`[StatefulTool] DeleteSandboxTool failed for ${toolId}:`, (err as Error).message) + } +} diff --git a/packages/server/src/sandbox/git-archive.ts b/packages/server/src/sandbox/git-archive.ts index 1948ee9..91cf5f0 100644 --- a/packages/server/src/sandbox/git-archive.ts +++ b/packages/server/src/sandbox/git-archive.ts @@ -6,7 +6,9 @@ * - Uses sandbox's git_push API endpoint */ -import type { SandboxInstance } from './scf-sandbox-manager.js' +import type { SandboxInstance } from './provider/types.js' +import { STATEFUL_WORKSPACE_ROOT } from '../lib/sandbox-config.js' +import { buildStatefulWorkspaceAuthEnv } from './stateful-sandbox-auth.js' // ─── Types ──────────────────────────────────────────────────────── @@ -63,46 +65,81 @@ export function isGitArchiveConfigured(): boolean { return !!(config?.repo && config?.token) } +/** + * Git archive vars for POST /api/workspace/init `env` only. + * PUT /api/workspace/env accepts CloudBase credential keys only (not GIT_ARCHIVE_*). + * Do not pass via StartSandboxInstance CustomConfiguration.Env — boot-time + * ENABLE_GIT_ARCHIVE blocks /health and fails AGS port binding. + */ +export function buildGitArchiveInitEnv(): Record { + const env: Record = {} + const repo = process.env.GIT_ARCHIVE_REPO?.trim() + const token = process.env.GIT_ARCHIVE_TOKEN?.trim() + const user = process.env.GIT_ARCHIVE_USER?.trim() + if (repo) env.GIT_ARCHIVE_REPO = repo + if (token) env.GIT_ARCHIVE_TOKEN = token + if (user) env.GIT_ARCHIVE_USER = user + const personal = process.env.GIT_PERSONAL_AUTH?.trim() + if (personal) env.GIT_PERSONAL_AUTH = personal + if (repo && token && user) env.ENABLE_GIT_ARCHIVE = 'true' + return { ...env, ...buildStatefulWorkspaceAuthEnv() } +} + +/** @deprecated Use {@link buildGitArchiveInitEnv} — name kept for callers. */ +export function buildGitArchiveWorkspaceEnv(): Record { + return buildGitArchiveInitEnv() +} + +/** Debug-only: AGS CustomConfiguration.Env array shape. */ +export function buildGitArchiveInstanceEnv(): Array<{ Name: string; Value: string }> { + return Object.entries(buildGitArchiveInitEnv()).map(([Name, Value]) => ({ Name, Value })) +} + /** * 将沙箱中的变更推送到 Git 归档仓库 * - * 通过沙箱的 /api/tools/git_push 端点执行 git 操作 + * 通过 沙箱业务镜像 POST /api/extend/git_push 端点执行 git 操作 * * @param sandbox 沙箱实例 * @param conversationId 会话 ID(用作分支名) * @param prompt 用户提示(用于生成 commit message) */ +export type ArchiveToGitResult = 'skipped' | 'ok' | 'fail' + export async function archiveToGit( sandbox: SandboxInstance, conversationId: string | undefined, prompt: string, -): Promise { - if (!conversationId) return +): Promise { + if (!conversationId) return 'skipped' const config = getConfig() if (!config) { console.log('[GitArchive] Not configured, skipping archive') - return + return 'skipped' } try { const promptSummary = prompt.slice(0, 50).replace(/\n/g, ' ') const commitMessage = `${conversationId}: ${promptSummary}` - const gitPushRes = await sandbox.request('/api/tools/git_push', { + const gitPushRes = await sandbox.request('/api/extend/git_push', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: commitMessage }), - signal: AbortSignal.timeout(30_000), + signal: AbortSignal.timeout(120_000), }) - if (gitPushRes.ok) { + const body = (await gitPushRes.json().catch(() => null)) as { success?: boolean; error?: string } | null + if (gitPushRes.ok && body?.success !== false) { console.log('[GitArchive] Push completed') - } else { - console.warn(`[GitArchive] Push failed: status=${gitPushRes.status}`) + return 'ok' } + console.warn('[GitArchive] Push failed') + return 'fail' } catch (err) { console.error('[GitArchive] Error:', (err as Error)?.message) + return 'fail' } } @@ -182,7 +219,17 @@ export async function deleteConversationViaSandbox( conversationId: string, sandboxCwd?: string, ): Promise { - const workspace = sandboxCwd || `/tmp/workspace/${envId}/${conversationId}` + const workspace = sandboxCwd || STATEFUL_WORKSPACE_ROOT + + // Stateful: one shared /home/user per env instance — never rm -rf the workspace root. + if (workspace === STATEFUL_WORKSPACE_ROOT || workspace === '/home/user') { + try { + await archiveToGit(sandbox, conversationId, `delete conversation ${conversationId}`) + } catch (err) { + console.warn(`[GitArchive] deleteConversationViaSandbox archive only failed: ${(err as Error).message}`) + } + return + } try { console.log(`[GitArchive] deleteConversationViaSandbox ${workspace}`) diff --git a/packages/server/src/sandbox/git-personal.ts b/packages/server/src/sandbox/git-personal.ts index de22073..252739c 100644 --- a/packages/server/src/sandbox/git-personal.ts +++ b/packages/server/src/sandbox/git-personal.ts @@ -1,4 +1,4 @@ -import type { SandboxInstance } from './scf-sandbox-manager.js' +import type { SandboxInstance } from './provider/types.js' import { getDb } from '../db/index.js' import { IGitOptions, parseGitUrl } from '../services/git/git.js' diff --git a/packages/server/src/sandbox/index.ts b/packages/server/src/sandbox/index.ts index 9615fa8..d2aa940 100644 --- a/packages/server/src/sandbox/index.ts +++ b/packages/server/src/sandbox/index.ts @@ -1,22 +1,28 @@ /** - * Sandbox Module - * - * Exports all sandbox-related utilities: - * - SCF sandbox manager for creating/managing cloud function sandboxes - * - Tool override for redirecting CLI tools to sandbox - * - Sandbox MCP proxy for CloudBase tools - * - Git archive for persisting workspace changes + * Sandbox module — stateful cloud sandbox (feature/stateful-infra). */ -export { - scfSandboxManager, - ScfSandboxManager, +export { getSandboxProvider, __resetSandboxProviderCacheForTests } from './provider/factory.js' +export type { + SandboxProvider, SandboxInstance, - type SandboxMode, - type SandboxProgressCallback, -} from './scf-sandbox-manager.js' - -export { createSandboxMcpClient, type SandboxMcpDeps } from './sandbox-mcp-proxy.js' + SandboxProgressCallback, + SandboxProgressMessage, + SessionEnv, + McpClientBundle, + ToolOverrideConfig, +} from './provider/types.js' +export { statefulProvider } from './provider/stateful-provider.js' +export { ensureStatefulTool, deleteStatefulToolForEnv, STATEFUL_TOOL_SETTINGS_KEY } from './ensure-stateful-tool.js' +export { + getTaskSandbox, + runCommandInSandbox, + downloadFileFromSandbox, + readFileFromSandbox, + writeFileToSandbox, + detectPackageManager, + ensureDevServerStarted, +} from './task-sandbox.js' export { archiveToGit, @@ -29,4 +35,16 @@ export { type GitArchiveConfig, } from './git-archive.js' -export { overrideTools, type ToolOverrideConfig, type ToolResult, type ToolContext } from './tool-override.js' +export { + overrideTools, + type ToolOverrideConfig as LegacyToolOverrideConfig, + type ToolResult, + type ToolContext, +} from './tool-override.js' + +export { + statefulReadTextFile, + statefulReadBinaryFile, + statefulWriteTextFile, + statefulRunCommand, +} from './stateful/e2b-native-client.js' diff --git a/packages/server/src/sandbox/preview-proxy.ts b/packages/server/src/sandbox/preview-proxy.ts new file mode 100644 index 0000000..84f2c96 --- /dev/null +++ b/packages/server/src/sandbox/preview-proxy.ts @@ -0,0 +1,144 @@ +/** + * Proxy browser preview requests to 沙箱业务镜像 /preview/{port}/ via stateful gateway auth headers. + */ + +import type { Context } from 'hono' +import type { SandboxInstance } from './provider/types.js' +import { TTYD_VIRTUAL_PORT } from './ttyd-preview.js' +import { resolveGatewayPreviewPort } from './ttyd-gateway-port.js' + +const HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailers', + 'transfer-encoding', + 'upgrade', + 'host', + 'content-length', +]) + +/** fetch() decompresses the body; forwarding Content-Encoding breaks browsers (ERR_CONTENT_DECODING_FAILED). */ +const STRIP_RESPONSE_HEADERS = new Set([...HOP_BY_HOP, 'content-encoding']) + +function buildUpstreamPreviewPath(port: string, subpath: string): string { + const portNum = port.replace(/[^\d]/g, '') || port + const suffix = subpath && subpath !== '/' ? (subpath.startsWith('/') ? subpath : `/${subpath}`) : '/' + return `/preview/${portNum}${suffix}` +} + +function normalizePort(port: string): string { + return port.replace(/[^\d]/g, '') || port +} + +function proxyPreviewPrefix(taskId: string, port: string): string { + const portNum = normalizePort(port) + return `/api/tasks/${taskId}/preview/${portNum}` +} + +function trwPreviewPrefix(port: string): string { + return `/preview/${normalizePort(port)}` +} + +/** Rewrite 沙箱业务镜像 vite base paths so subresources load through OpenVibeCoding 预览反代 route. */ +export function rewritePreviewPaths(body: string, taskId: string, port: string): string { + const from = trwPreviewPrefix(port) + const to = proxyPreviewPrefix(taskId, port) + if (!body.includes(from)) return body + return body.split(from).join(to) +} + +/** Exported for tests — ttyd HTML must not be string-rewritten (breaks WS client). */ +export function shouldRewritePreviewBody(contentType: string | null, port: string): boolean { + if (normalizePort(port) === String(TTYD_VIRTUAL_PORT)) return false + if (!contentType) return false + const ct = contentType.split(';')[0]?.trim().toLowerCase() ?? '' + return ( + ct.includes('text/html') || + ct.includes('javascript') || + ct.includes('text/css') || + ct === 'application/json' || + ct === 'text/plain' + ) +} + +function rewriteLocationHeader(location: string, taskId: string, port: string, sandboxBaseUrl: string): string { + try { + const loc = new URL(location, sandboxBaseUrl) + const previewPrefix = trwPreviewPrefix(port) + if (loc.pathname.startsWith(previewPrefix)) { + const rest = loc.pathname.slice(previewPrefix.length) || '/' + const proxyPath = `${proxyPreviewPrefix(taskId, port)}${rest}` + return `${proxyPath}${loc.search}` + } + } catch { + // keep original + } + return location +} + +export async function proxyTaskPreview( + c: Context, + sandbox: SandboxInstance, + taskId: string, + port: string, + subpath: string, +): Promise { + const authHeaders = await sandbox.getAuthHeaders() + const publicPortNum = Number(normalizePort(port)) + const gatewayPort = + publicPortNum === TTYD_VIRTUAL_PORT ? String(await resolveGatewayPreviewPort(sandbox, TTYD_VIRTUAL_PORT)) : port + const upstreamPath = buildUpstreamPreviewPath(gatewayPort, subpath) + const query = c.req.url.includes('?') ? new URL(c.req.url).search : '' + const targetUrl = `${sandbox.baseUrl}${upstreamPath}${query}` + + const forwardHeaders = new Headers() + for (const [key, value] of c.req.raw.headers.entries()) { + const lower = key.toLowerCase() + if (HOP_BY_HOP.has(lower)) continue + if (lower === 'cookie' || lower === 'authorization') continue + forwardHeaders.set(key, value) + } + for (const [key, value] of Object.entries(authHeaders)) { + forwardHeaders.set(key, value) + } + + const method = c.req.method + const hasBody = method !== 'GET' && method !== 'HEAD' + const upstream = await fetch(targetUrl, { + method, + headers: forwardHeaders, + body: hasBody ? c.req.raw.body : undefined, + redirect: 'manual', + signal: AbortSignal.timeout(120_000), + }) + + const outHeaders = new Headers() + upstream.headers.forEach((value, key) => { + if (STRIP_RESPONSE_HEADERS.has(key.toLowerCase())) return + if (key.toLowerCase() === 'location') { + outHeaders.set(key, rewriteLocationHeader(value, taskId, port, sandbox.baseUrl)) + return + } + outHeaders.set(key, value) + }) + + const contentType = upstream.headers.get('content-type') + if (shouldRewritePreviewBody(contentType, port)) { + const raw = await upstream.text() + const rewritten = rewritePreviewPaths(raw, taskId, port) + return new Response(rewritten, { + status: upstream.status, + statusText: upstream.statusText, + headers: outHeaders, + }) + } + + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: outHeaders, + }) +} diff --git a/packages/server/src/sandbox/preview-ws-proxy.ts b/packages/server/src/sandbox/preview-ws-proxy.ts new file mode 100644 index 0000000..d40b481 --- /dev/null +++ b/packages/server/src/sandbox/preview-ws-proxy.ts @@ -0,0 +1,186 @@ +/** + * WebSocket upgrade proxy: browser → OpenVibeCoding → TRW /preview/{port}/ (vite HMR, ttyd). + */ + +import type { IncomingMessage, Server } from 'node:http' +import { URL } from 'node:url' +import WebSocket, { WebSocketServer } from 'ws' +import { resolveSandboxForTaskWs } from './ws-auth.js' +import { TTYD_VIRTUAL_PORT } from './ttyd-preview.js' +import { resolveGatewayPreviewPort } from './ttyd-gateway-port.js' + +const PREVIEW_WS_RE = /^\/api\/tasks\/([^/]+)\/preview\/(\d+)(\/.*)?$/ + +const wss = new WebSocketServer({ noServer: true }) + +function buildUpstreamPath(port: string, subpath: string | undefined): string { + const suffix = subpath && subpath !== '/' ? (subpath.startsWith('/') ? subpath : `/${subpath}`) : '/' + return `/preview/${port}${suffix}` +} + +/** Gateway base includes a path prefix (e.g. /v1/sandbox/-); WS must use the full path, not host-only. */ +export function buildGatewayWebSocketUrl(baseUrl: string, previewPath: string, query = ''): string { + const base = new URL(baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`) + const prefix = base.pathname.replace(/\/$/, '') + const normalized = previewPath.startsWith('/') ? previewPath : `/${previewPath}` + const wsProtocol = base.protocol === 'https:' ? 'wss:' : 'ws:' + return `${wsProtocol}//${base.host}${prefix}${normalized}${query}` +} + +/** + * Gateway WS upgrade: send sandbox data-plane auth only. + * Do not forward browser Origin/Referer (CloudRun host) — gateway returns 403 on upgrade. + */ +export function buildUpstreamGatewayWsHeaders(authHeaders: Record): Record { + return { ...authHeaders } +} + +/** Browser ttyd uses Sec-WebSocket-Protocol: tty; upstream must match or ttyd closes immediately. */ +export function parseClientWsProtocols(req: IncomingMessage): string[] | undefined { + const raw = req.headers['sec-websocket-protocol'] + if (!raw) return undefined + const protocols = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + return protocols.length > 0 ? protocols : undefined +} + +export function connectUpstreamPreviewWebSocket( + wsUrl: string, + protocols: string[] | undefined, + headers: Record, +): WebSocket { + const options = { headers, perMessageDeflate: false as const } + return protocols && protocols.length > 0 ? new WebSocket(wsUrl, protocols, options) : new WebSocket(wsUrl, options) +} + +type WsFrame = { data: WebSocket.RawData; isBinary: boolean } + +function isBinaryFrame(isBinary: boolean, data: WebSocket.RawData): boolean { + if (typeof isBinary === 'boolean') return isBinary + if (typeof data === 'string') return false + // ttyd/vite HMR use binary frames; `undefined` must not default to text. + return true +} + +function sendFrame(target: WebSocket, frame: WsFrame): void { + if (target.readyState !== WebSocket.OPEN) return + target.send(frame.data, { binary: isBinaryFrame(frame.isBinary, frame.data) }) +} + +function flushQueue(target: WebSocket, queue: WsFrame[]): void { + while (queue.length > 0) { + const frame = queue.shift() + if (frame) sendFrame(target, frame) + } +} + +/** Bidirectional bridge; queues early ttyd handshake until upstream is open. */ +export function bridgeSockets(client: WebSocket, upstream: WebSocket): void { + const clientToUpstream: WsFrame[] = [] + const upstreamToClient: WsFrame[] = [] + + const onClientMessage = (data: WebSocket.RawData, isBinary: boolean) => { + const frame = { data, isBinary } + if (upstream.readyState === WebSocket.OPEN) sendFrame(upstream, frame) + else clientToUpstream.push(frame) + } + const onUpstreamMessage = (data: WebSocket.RawData, isBinary: boolean) => { + const frame = { data, isBinary } + if (client.readyState === WebSocket.OPEN) sendFrame(client, frame) + else upstreamToClient.push(frame) + } + + client.on('message', onClientMessage) + upstream.on('message', onUpstreamMessage) + + const flushPending = () => { + flushQueue(upstream, clientToUpstream) + flushQueue(client, upstreamToClient) + } + + if (upstream.readyState === WebSocket.OPEN) flushPending() + else upstream.once('open', flushPending) + + const closeBoth = () => { + client.removeListener('message', onClientMessage) + upstream.removeListener('message', onUpstreamMessage) + if (client.readyState === WebSocket.OPEN) client.close() + if (upstream.readyState === WebSocket.OPEN) upstream.close() + } + + client.on('close', closeBoth) + upstream.on('close', closeBoth) +} + +export function attachPreviewWebSocketProxy(server: Server): void { + server.prependListener('upgrade', (req, socket, head) => { + const rawUrl = req.url ?? '/' + const pathname = rawUrl.split('?')[0] ?? rawUrl + const match = PREVIEW_WS_RE.exec(pathname) + if (!match) return + + const taskId = match[1] + const port = match[2] + const subpath = match[3] ?? '/' + const query = rawUrl.includes('?') ? rawUrl.slice(rawUrl.indexOf('?')) : '' + + void (async () => { + const sandbox = await resolveSandboxForTaskWs(req, taskId) + if (!sandbox) { + if (socket.writable) socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') + socket.destroy() + return + } + + const publicPortNum = Number(port) + const gatewayPort = + publicPortNum === TTYD_VIRTUAL_PORT ? String(await resolveGatewayPreviewPort(sandbox, TTYD_VIRTUAL_PORT)) : port + const previewPath = buildUpstreamPath(gatewayPort, subpath) + const wsUrl = buildGatewayWebSocketUrl(sandbox.baseUrl, previewPath, query) + const upstreamHeaders = buildUpstreamGatewayWsHeaders(await sandbox.getAuthHeaders()) + const clientProtocols = parseClientWsProtocols(req) + + wss.handleUpgrade(req, socket, head, (clientWs) => { + const upstreamWs = connectUpstreamPreviewWebSocket(wsUrl, clientProtocols, upstreamHeaders) + bridgeSockets(clientWs, upstreamWs) + + clientWs.on('ping', (data) => { + if (upstreamWs.readyState === WebSocket.OPEN) upstreamWs.ping(data) + }) + clientWs.on('pong', (data) => { + if (upstreamWs.readyState === WebSocket.OPEN) upstreamWs.pong(data) + }) + upstreamWs.on('ping', (data) => { + if (clientWs.readyState === WebSocket.OPEN) clientWs.ping(data) + }) + upstreamWs.on('pong', (data) => { + if (clientWs.readyState === WebSocket.OPEN) clientWs.pong(data) + }) + + upstreamWs.on('unexpected-response', (_proxyReq, res) => { + console.warn('[preview-ws-proxy] upstream rejected WebSocket upgrade') + console.error('[preview-ws-proxy] upstream upgrade HTTP status:', res.statusCode) + if (socket.writable && !socket.destroyed) { + socket.write(`HTTP/1.1 ${res.statusCode ?? 502} ${res.statusMessage ?? 'Bad Gateway'}\r\n\r\n`) + } + clientWs.close() + upstreamWs.terminate() + }) + + upstreamWs.on('error', () => { + console.warn('[preview-ws-proxy] upstream WebSocket error') + clientWs.close() + }) + + clientWs.on('error', () => { + upstreamWs.terminate() + }) + }) + })().catch((err) => { + console.warn('[preview-ws-proxy] handler error:', (err as Error).message) + socket.destroy() + }) + }) +} diff --git a/packages/server/src/sandbox/provider/factory.ts b/packages/server/src/sandbox/provider/factory.ts new file mode 100644 index 0000000..47b9f57 --- /dev/null +++ b/packages/server/src/sandbox/provider/factory.ts @@ -0,0 +1,18 @@ +/** + * Stateful sandbox provider factory (single backend on this branch). + */ + +import type { SandboxProvider } from './types.js' +import { statefulProvider } from './stateful-provider.js' + +let cached: SandboxProvider | null = null + +export function getSandboxProvider(): SandboxProvider { + if (!cached) cached = statefulProvider + return cached +} + +/** Only for tests — reset the cached provider. */ +export function __resetSandboxProviderCacheForTests(): void { + cached = null +} diff --git a/packages/server/src/sandbox/provider/stateful-provider.ts b/packages/server/src/sandbox/provider/stateful-provider.ts new file mode 100644 index 0000000..e07c866 --- /dev/null +++ b/packages/server/src/sandbox/provider/stateful-provider.ts @@ -0,0 +1,615 @@ +/** + * Stateful sandbox provider (CloudBase AGS control plane + 沙箱业务镜像 data plane). + * + * 沙箱业务镜像 workspace protocol: + * - PUT /api/workspace/env inject credentials + * - POST /api/workspace/init initialize workspace + * - GET /health health probe + * - POST /api/workspace/snapshot explicit COS snapshot flush + * + * Two-layer control plane: + * - Tool = template (sdt-xxx). ensureStatefulTool() per envId (DB → AGS name → CreateTool). + * - Instance = runtime container. + * shared: one instance per env/tool (RUNNING reuse, PAUSED resume, else Start). + * isolated: per-task instance (task sandboxId → resume/reuse, else Start). + * - Process cache: healthy in-memory instance per cache key (env vs task). + * + * Auth: TCB_API_KEY → X-Cloudbase-Authorization; optional TCB_ACCESS_TOKEN → X-Access-Token + * when ENABLE_AUTH_MODE=true (StartSandboxInstance omits AuthMode NONE). + * Routing: E2b-Sandbox-Id + E2b-Sandbox-Port: 9000 headers route to instance. + */ + +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { existsSync } from 'node:fs' + +import { normalizeSandboxMode, type SandboxInstanceMode } from '../../lib/sandbox-config.js' +import type { + AcquireContext, + DeleteConversationContext, + McpClientBundle, + McpDeps, + PrepareContext, + ReleaseContext, + SandboxInstance, + SandboxProgressCallback, + SandboxProvider, + SessionEnv, + ToolOverrideConfig, + ToolOverrideHosting, +} from './types.js' +import { STATEFUL_WORKSPACE_ROOT } from '../../lib/sandbox-config.js' +import { buildGitArchiveInitEnv } from '../git-archive.js' +import { callAgsManagerApi as requestAgsManagerApi } from '../../lib/cloudbase-ags-api.js' +import { ensureStatefulTool, resolveStatefulGatewayUrl } from '../ensure-stateful-tool.js' +import { startStatefulInstanceWithWarmup } from '../stateful-tool-warmup.js' +import { + assertStatefulSandboxAuthConfig, + getTcbAccessToken, + isStatefulAuthModeEnabled, +} from '../stateful-sandbox-auth.js' +import { buildDataPlaneHeaders, SANDBOX_BUSINESS_IMAGE_PORT } from '../stateful/gateway.js' +import { createStatefulMcpClient } from '../stateful/stateful-mcp-client.js' +import { resolveAgsSandboxTimeout } from '../stateful-sandbox-ttl.js' + +// ─── Constants ──────────────────────────────────────────────────────────── + +const HEALTH_TIMEOUT_MS = 5000 +const READY_TIMEOUT_MS = 120_000 +const READY_POLL_INTERVAL_MS = 3000 +const PREPARE_INIT_TIMEOUT_MS = 300_000 + +// ─── Config from env ────────────────────────────────────────────────────── + +interface StatefulRuntimeConfig { + tcbApiKey: string + enableAuthMode: boolean + accessToken: string + sandboxBaseUrl: string + toolId: string + preCreatedSandboxId: string + managerSecretId: string + managerSecretKey: string + managerToken: string + managerEnvId: string +} + +function readStatefulRuntimeConfig(envId: string, toolId: string): StatefulRuntimeConfig { + assertStatefulSandboxAuthConfig() + const tcbApiKey = process.env.TCB_API_KEY || '' + const enableAuthMode = isStatefulAuthModeEnabled() + const accessToken = getTcbAccessToken() + const sandboxBaseUrl = resolveStatefulGatewayUrl(envId) + const preCreatedSandboxId = process.env.STATEFUL_SANDBOX_ID || '' + const managerSecretId = process.env.TCB_SECRET_ID || '' + const managerSecretKey = process.env.TCB_SECRET_KEY || '' + const managerToken = process.env.TCB_TOKEN || process.env.TENCENTCLOUD_SESSIONTOKEN || '' + const managerEnvId = process.env.TCB_ENV_ID || envId + + if (!tcbApiKey) throw new Error('Missing TCB_API_KEY (required for stateful sandbox data-plane auth)') + if (!preCreatedSandboxId && !toolId) { + throw new Error('Stateful sandbox requires a tool id (ensureStatefulTool failed)') + } + if (!preCreatedSandboxId && (!managerSecretId || !managerSecretKey || !managerEnvId)) { + throw new Error('TCB_ENV_ID + TCB_SECRET_ID/KEY required for sandbox instance lifecycle') + } + + return { + tcbApiKey, + enableAuthMode, + accessToken, + sandboxBaseUrl, + toolId, + preCreatedSandboxId, + managerSecretId, + managerSecretKey, + managerToken, + managerEnvId, + } +} + +function buildStatefulDataPlaneHeaders( + cfg: StatefulRuntimeConfig, + sandboxId: string, + port: number = SANDBOX_BUSINESS_IMAGE_PORT, +): Record { + return buildDataPlaneHeaders({ + tcbApiKey: cfg.tcbApiKey, + sandboxId, + port, + accessToken: cfg.enableAuthMode ? cfg.accessToken : undefined, + }) +} + +// ─── Instance meta bag ──────────────────────────────────────────────────── + +interface StatefulMetaBag { + envId: string + conversationId: string + toolId: string + tcbApiKey: string + enableAuthMode: boolean + accessToken: string + sandboxMode: SandboxInstanceMode + cacheKey: string +} + +function resolveAcquireSandboxMode(ctx: AcquireContext): SandboxInstanceMode { + const fromBackend = ctx.backendOptions?.backend === 'stateful' ? ctx.backendOptions.sandboxMode : undefined + const fromMeta = typeof ctx.meta?.sandboxMode === 'string' ? ctx.meta.sandboxMode : undefined + return normalizeSandboxMode(fromBackend ?? fromMeta) +} + +function buildInstanceCacheKey(envId: string, conversationId: string, mode: SandboxInstanceMode): string { + return mode === 'isolated' ? `task:${envId}:${conversationId}` : `env:${envId}` +} + +function buildStatefulInstance(args: { + sandboxId: string + toolId: string + baseUrl: string + envId: string + conversationId: string + cfg: StatefulRuntimeConfig + sandboxMode: SandboxInstanceMode + cacheKey: string +}): SandboxInstance { + const { sandboxId, toolId, baseUrl, envId, conversationId, cfg, sandboxMode, cacheKey } = args + const meta: StatefulMetaBag = { + envId, + conversationId, + toolId, + tcbApiKey: cfg.tcbApiKey, + enableAuthMode: cfg.enableAuthMode, + accessToken: cfg.accessToken, + sandboxMode, + cacheKey, + } + const authHeaders = () => buildStatefulDataPlaneHeaders(cfg, sandboxId) + const initialHeaders = authHeaders() + return { + backend: 'stateful', + id: sandboxId, + templateId: toolId, + baseUrl, + meta: meta as unknown as Record, + mcpConfig: { + type: 'http', + url: `${baseUrl}/mcp`, + headers: initialHeaders, + }, + async getAuthHeaders() { + return authHeaders() + }, + async request(p, opts) { + return fetch(`${baseUrl}${p}`, { + ...opts, + headers: { + ...authHeaders(), + ...((opts?.headers as Record | undefined) ?? {}), + }, + }) + }, + } +} + +// ─── Tool override module path ──────────────────────────────────────────── +// Stateful runtime reuses tool-override.cjs; CLI patch consumes protocol-neutral +// payload (沙箱业务镜像 /api/tools/* + e2b envd). Only {url, headers} differ per instance. + +function getStatefulToolOverridePath(): string { + const here = path.dirname(fileURLToPath(import.meta.url)) + const candidates = [ + // tsup dist runtime (here ~= packages/server/dist/sandbox/provider) + path.resolve(here, '../tool-override.cjs'), + // source runtime via tsx (here ~= packages/server/src/sandbox/provider) + path.resolve(here, '../../../dist/sandbox/tool-override.cjs'), + // fallback for unusual bundle layouts + path.resolve(here, 'sandbox/tool-override.cjs'), + path.resolve(here, '../../../../dist/sandbox/tool-override.cjs'), + ] + const hit = candidates.find((p) => existsSync(p)) + return hit || candidates[0] +} + +// ─── AGS Manager API (control plane) ────────────────────────────────────── +// Uses @cloudbase/manager-node CloudService('ags', '2025-09-20') for +// StartSandboxInstance / DescribeSandboxInstanceList / Pause / Resume / Stop. + +async function callAgsManagerApi( + action: string, + param: Record, + cfg: StatefulRuntimeConfig, +): Promise> { + return requestAgsManagerApi(action, param, { + secretId: cfg.managerSecretId, + secretKey: cfg.managerSecretKey, + token: cfg.managerToken, + envId: cfg.managerEnvId, + }) +} + +async function startStatefulInstance(cfg: StatefulRuntimeConfig, toolId: string): Promise { + const startParam: Record = { ToolId: toolId, Timeout: resolveAgsSandboxTimeout() } + if (!cfg.enableAuthMode) startParam.AuthMode = 'NONE' + const result = (await callAgsManagerApi('StartSandboxInstance', startParam, cfg)) as Record + const data = result?.data as Record | undefined + const instanceObj = result?.Instance as Record | undefined + const instanceId = String(result?.InstanceId || instanceObj?.InstanceId || data?.InstanceId || '') || '' + if (!instanceId) { + throw new Error(`StartSandboxInstance returned no InstanceId: ${JSON.stringify(result)}`) + } + return instanceId +} + +interface StatefulInstanceStatus { + instanceId: string + status: string + toolId: string | null +} + +async function describeAgsInstances( + cfg: StatefulRuntimeConfig, + opts: { toolId?: string; instanceIds?: string[] } = {}, +): Promise { + const result = await callAgsManagerApi( + 'DescribeSandboxInstanceList', + { + ...(opts.toolId ? { ToolId: opts.toolId } : {}), + ...(opts.instanceIds?.length ? { InstanceIds: opts.instanceIds } : {}), + Limit: 100, + }, + cfg, + ) + const data = result?.data as Record | undefined + const rows = (result?.InstanceSet || data?.InstanceSet || []) as Array> + return rows.map((it) => ({ + instanceId: String(it.InstanceId || ''), + status: String(it.Status || ''), + toolId: it.ToolId ? String(it.ToolId) : null, + })) +} + +async function stopStatefulInstance(cfg: StatefulRuntimeConfig, instanceId: string): Promise { + await callAgsManagerApi('StopSandboxInstance', { InstanceId: instanceId }, cfg) +} + +async function pauseStatefulInstance(cfg: StatefulRuntimeConfig, instanceId: string): Promise { + await callAgsManagerApi('PauseSandboxInstance', { InstanceId: instanceId }, cfg) +} + +async function resumeStatefulInstance(cfg: StatefulRuntimeConfig, instanceId: string): Promise { + await callAgsManagerApi('ResumeSandboxInstance', { InstanceId: instanceId }, cfg) +} + +function pickPrimaryInstance(candidates: StatefulInstanceStatus[]): StatefulInstanceStatus | null { + const byPriority = ['RUNNING', 'PAUSED', 'RESUME_FAILED'] + for (const status of byPriority) { + const hit = candidates.find((c) => c.status === status) + if (hit) return hit + } + return null +} + +async function ensureSingleEnvInstance( + cfg: StatefulRuntimeConfig, + toolId: string, + onProgress?: SandboxProgressCallback, +): Promise<{ sandboxId: string; created: boolean }> { + const discover = await describeAgsInstances(cfg, { toolId }) + const active = discover.filter((it) => ['RUNNING', 'PAUSED', 'RESUME_FAILED'].includes(it.status)) + const primary = pickPrimaryInstance(active) + if (!primary) { + onProgress?.({ + phase: 'instance_start', + message: '正在启动环境沙箱实例(共享模式)...\n', + }) + const sandboxId = await startStatefulInstanceWithWarmup(() => startStatefulInstance(cfg, toolId), onProgress) + return { sandboxId, created: true } + } + + // Keep one instance per env/tool. Extra active instances are drift; stop them best-effort. + const redundant = active.filter((it) => it.instanceId !== primary.instanceId) + for (const item of redundant) { + try { + await stopStatefulInstance(cfg, item.instanceId) + } catch (err) { + console.warn('[StatefulProvider] failed to stop redundant instance:', item.instanceId, (err as Error).message) + } + } + + if (primary.status !== 'RUNNING') { + onProgress?.({ + phase: 'instance_resume', + message: '正在恢复环境中的沙箱实例...\n', + }) + await resumeStatefulInstance(cfg, primary.instanceId) + } else { + onProgress?.({ + phase: 'instance_reuse_shared', + message: '复用环境中的沙箱实例(多任务共享)...\n', + }) + } + return { sandboxId: primary.instanceId, created: false } +} + +/** Per-task instance: reuse task.sandboxId when healthy; otherwise start a dedicated instance. */ +async function ensureTaskInstance( + cfg: StatefulRuntimeConfig, + toolId: string, + preferredInstanceId?: string | null, + onProgress?: SandboxProgressCallback, +): Promise<{ sandboxId: string; created: boolean }> { + if (preferredInstanceId) { + const listed = await describeAgsInstances(cfg, { instanceIds: [preferredInstanceId] }) + const hit = listed.find((it) => it.instanceId === preferredInstanceId) + if (hit && ['RUNNING', 'PAUSED', 'RESUME_FAILED'].includes(hit.status)) { + if (hit.status !== 'RUNNING') { + onProgress?.({ + phase: 'instance_resume', + message: '正在恢复任务沙箱实例...\n', + }) + await resumeStatefulInstance(cfg, hit.instanceId) + } else { + onProgress?.({ + phase: 'instance_reuse_task', + message: '复用本任务的沙箱实例...\n', + }) + } + return { sandboxId: hit.instanceId, created: false } + } + } + + onProgress?.({ + phase: 'instance_start', + message: '正在为当前任务启动沙箱实例(隔离模式)...\n', + }) + const sandboxId = await startStatefulInstanceWithWarmup(() => startStatefulInstance(cfg, toolId), onProgress) + return { sandboxId, created: true } +} + +// ─── Health check ───────────────────────────────────────────────────────── + +async function checkHealth(baseUrl: string, headers: Record): Promise { + try { + const res = await fetch(`${baseUrl}/health`, { + headers, + signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS), + }) + return res.ok + } catch { + return false + } +} + +async function waitForReady(baseUrl: string, sandboxId: string, cfg: StatefulRuntimeConfig): Promise { + const headers = buildStatefulDataPlaneHeaders(cfg, sandboxId) + const start = Date.now() + while (Date.now() - start < READY_TIMEOUT_MS) { + if (await checkHealth(baseUrl, headers)) return + await new Promise((r) => setTimeout(r, READY_POLL_INTERVAL_MS)) + } + throw new Error(`Sandbox instance ${sandboxId} not healthy within ${READY_TIMEOUT_MS / 1000}s`) +} + +// ─── The provider ───────────────────────────────────────────────────────── + +class StatefulProvider implements SandboxProvider { + readonly backend = 'stateful' as const + + /** Cache instances per cache key (default: envId). */ + private readonly instanceCache = new Map() + + private cacheKey(ctx: AcquireContext, mode?: SandboxInstanceMode): string { + const m = mode ?? resolveAcquireSandboxMode(ctx) + return buildInstanceCacheKey(ctx.envId, ctx.conversationId, m) + } + + async acquire(ctx: AcquireContext, onProgress?: SandboxProgressCallback): Promise { + const userId = typeof ctx.meta?.userId === 'string' ? ctx.meta.userId : undefined + const sandboxMode = resolveAcquireSandboxMode(ctx) + const preferredSandboxId = typeof ctx.meta?.preferredSandboxId === 'string' ? ctx.meta.preferredSandboxId : null + const toolId = await ensureStatefulTool(ctx.envId, { + userId, + taskId: ctx.conversationId, + onProgress, + }) + const cfg = readStatefulRuntimeConfig(ctx.envId, toolId) + const key = this.cacheKey(ctx, sandboxMode) + + // Reuse cached instance if still healthy. + const cached = this.instanceCache.get(key) + if (cached) { + const headers = await cached.getAuthHeaders() + if (await checkHealth(cached.baseUrl, headers)) { + onProgress?.({ + phase: 'instance_reuse_session', + message: '复用本会话的沙箱连接...\n', + }) + return cached + } + this.instanceCache.delete(key) + } + + let sandboxId: string + if (cfg.preCreatedSandboxId) { + onProgress?.({ phase: 'wait_ready', message: '连接已有沙箱实例...\n' }) + sandboxId = cfg.preCreatedSandboxId + } else { + if (sandboxMode === 'isolated') { + const ensured = await ensureTaskInstance(cfg, cfg.toolId, preferredSandboxId, onProgress) + sandboxId = ensured.sandboxId + } else { + const ensured = await ensureSingleEnvInstance(cfg, cfg.toolId, onProgress) + sandboxId = ensured.sandboxId + } + onProgress?.({ phase: 'wait_ready', message: '确认沙箱实例健康状态...\n' }) + await waitForReady(cfg.sandboxBaseUrl, sandboxId, cfg) + } + + // Final health check (covers pre-created path too). + const headers = buildStatefulDataPlaneHeaders(cfg, sandboxId) + if (!(await checkHealth(cfg.sandboxBaseUrl, headers))) { + throw new Error(`Sandbox ${sandboxId} not healthy at ${cfg.sandboxBaseUrl}`) + } + + const inst = buildStatefulInstance({ + sandboxId, + toolId: cfg.toolId || 'pre-created', + baseUrl: cfg.sandboxBaseUrl, + envId: ctx.envId, + conversationId: ctx.conversationId, + cfg, + sandboxMode, + cacheKey: key, + }) + + this.instanceCache.set(key, inst) + onProgress?.({ phase: 'ready', message: '沙箱已就绪\n' }) + return inst + } + + async getExisting(ctx: AcquireContext): Promise { + const key = this.cacheKey(ctx, resolveAcquireSandboxMode(ctx)) + const cached = this.instanceCache.get(key) + if (!cached) return null + const headers = await cached.getAuthHeaders() + return (await checkHealth(cached.baseUrl, headers)) ? cached : null + } + + async prepare(inst: SandboxInstance, ctx: PrepareContext, onProgress?: SandboxProgressCallback): Promise { + onProgress?.({ phase: 'init_mcp', message: '初始化 workspace...\n' }) + + // Single-shot: POST /api/workspace/init handles ensureWorkspace + env injection. + // Returns immediately when workspace ready (idempotent on warm restarts). + try { + const res = await inst.request('/api/workspace/init', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + env: { + CLOUDBASE_ENV_ID: ctx.credentials.envId, + TENCENTCLOUD_SECRETID: ctx.credentials.secretId, + TENCENTCLOUD_SECRETKEY: ctx.credentials.secretKey, + ...(ctx.credentials.sessionToken ? { TENCENTCLOUD_SESSIONTOKEN: ctx.credentials.sessionToken } : {}), + INTEGRATION_IDE: 'codebuddy', + WORKSPACE_FOLDER_PATHS: ctx.workspaceHint || STATEFUL_WORKSPACE_ROOT, + ...buildGitArchiveInitEnv(), + }, + }), + signal: AbortSignal.timeout(PREPARE_INIT_TIMEOUT_MS), + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`/api/workspace/init failed: ${res.status} ${text.slice(0, 200)}`) + } + const data = (await res.json().catch(() => null)) as { success?: boolean; result?: { workspace?: string } } | null + const workspace = data?.result?.workspace ?? STATEFUL_WORKSPACE_ROOT + onProgress?.({ phase: 'ready', message: 'workspace 已就绪\n' }) + return { workspace } + } catch (err) { + console.warn( + `[StatefulProvider] /api/workspace/init failed, falling back to ${STATEFUL_WORKSPACE_ROOT}:`, + (err as Error).message, + ) + return { workspace: STATEFUL_WORKSPACE_ROOT } + } + } + + async release(inst: SandboxInstance, ctx: ReleaseContext): Promise { + // AGS persistence: COS snapshot is auto-managed by 沙箱业务镜像 (periodic 60s + shutdown). + // We only flush explicitly when the caller asks for it (rare path). + const flushSnapshot = ctx.backendOptions?.backend === 'stateful' && ctx.backendOptions.flushSnapshot === true + if (!flushSnapshot) return + + try { + const res = await inst.request('/api/workspace/snapshot', { + method: 'POST', + signal: AbortSignal.timeout(60_000), + }) + if (!res.ok) { + console.warn(`[StatefulProvider] snapshot flush returned ${res.status}`) + } + } catch (err) { + console.warn('[StatefulProvider] snapshot flush failed:', (err as Error).message) + } + } + + async createMcpClient(deps: McpDeps): Promise { + return createStatefulMcpClient(deps) + } + + async getToolOverrideConfig(inst: SandboxInstance, hosting?: ToolOverrideHosting): Promise { + const headers = await inst.getAuthHeaders() + return { + url: inst.baseUrl, + headers, + modulePath: getStatefulToolOverridePath(), + ...(hosting ? { hosting } : {}), + } + } + + async getPreviewBaseUrl(inst: SandboxInstance): Promise { + // AGS routes preview via the same gateway. No separate gateway provisioning. + return `${inst.baseUrl}/preview` + } + + async destroy(inst: SandboxInstance): Promise { + try { + const meta = inst.meta as unknown as StatefulMetaBag + const cfg = readStatefulRuntimeConfig(meta.envId, meta.toolId) + await stopStatefulInstance(cfg, inst.id) + } catch (err) { + console.warn('[StatefulProvider] StopSandboxInstance failed:', (err as Error).message) + } + const meta = inst.meta as unknown as StatefulMetaBag + if (meta.cacheKey) { + this.instanceCache.delete(meta.cacheKey) + } else { + for (const [k, v] of this.instanceCache) { + if (v.id === inst.id) this.instanceCache.delete(k) + } + } + } + + async deleteConversation(inst: SandboxInstance, ctx: DeleteConversationContext): Promise { + const meta = inst.meta as unknown as StatefulMetaBag + const mode = normalizeSandboxMode(ctx.sandboxMode ?? meta.sandboxMode) + if (mode === 'isolated') { + await this.destroy(inst) + return + } + // shared: one /home/user per env instance — no per-task workspace teardown in 沙箱业务镜像. + } + + /** Stop the single shared AGS instance for envId (after all tasks removed). */ + async stopSharedEnvSandbox(envId: string): Promise { + const toolId = await ensureStatefulTool(envId) + const cfg = readStatefulRuntimeConfig(envId, toolId) + const discover = await describeAgsInstances(cfg, { toolId }) + const active = discover.filter((it) => ['RUNNING', 'PAUSED', 'RESUME_FAILED'].includes(it.status)) + const primary = pickPrimaryInstance(active) + if (!primary) return false + await stopStatefulInstance(cfg, primary.instanceId) + for (const key of [...this.instanceCache.keys()]) { + if (key === `env:${envId}` || key.startsWith(`env:${envId}:`)) { + this.instanceCache.delete(key) + } + } + return true + } + + // ── Optional admin helpers (not on SandboxProvider interface, but useful) ── + + async pause(inst: SandboxInstance): Promise { + const meta = inst.meta as unknown as StatefulMetaBag + await pauseStatefulInstance(readStatefulRuntimeConfig(meta.envId, meta.toolId), inst.id) + } + + async resume(inst: SandboxInstance): Promise { + const meta = inst.meta as unknown as StatefulMetaBag + await resumeStatefulInstance(readStatefulRuntimeConfig(meta.envId, meta.toolId), inst.id) + } +} + +export const statefulProvider = new StatefulProvider() diff --git a/packages/server/src/sandbox/provider/types.ts b/packages/server/src/sandbox/provider/types.ts new file mode 100644 index 0000000..3475dd3 --- /dev/null +++ b/packages/server/src/sandbox/provider/types.ts @@ -0,0 +1,151 @@ +/** + * Stateful sandbox provider types (feature/stateful-infra branch). + */ + +export type SandboxBackend = 'stateful' + +export interface SandboxProgressMessage { + phase: string + message: string +} + +export type SandboxProgressCallback = (m: SandboxProgressMessage) => void + +export interface AcquireContext { + envId: string + conversationId: string + backendOptions?: StatefulAcquireOptions + meta?: Record +} + +export interface PrepareContext { + credentials: { + envId: string + secretId: string + secretKey: string + sessionToken?: string + } + workspaceHint?: string + codingMode?: boolean + backendOptions?: StatefulPrepareOptions + meta?: Record +} + +export interface ReleaseContext { + conversationId: string + prompt?: string + reason: 'completed' | 'cancelled' | 'error' + backendOptions?: StatefulReleaseOptions + meta?: Record +} + +export interface DeleteConversationContext { + envId: string + conversationId: string + sandboxCwd?: string + sandboxMode?: 'shared' | 'isolated' +} + +export interface StatefulAcquireOptions { + backend: 'stateful' + /** shared = one AGS instance per envId; isolated = one instance per task (conversationId). */ + sandboxMode?: 'shared' | 'isolated' +} + +export interface StatefulPrepareOptions { + backend: 'stateful' +} + +export interface StatefulReleaseOptions { + backend: 'stateful' + flushSnapshot?: boolean +} + +export type BackendAcquireOptions = StatefulAcquireOptions +export type BackendPrepareOptions = StatefulPrepareOptions +export type BackendReleaseOptions = StatefulReleaseOptions + +export interface SessionEnv { + workspace: string + vitePort?: number + meta?: Record +} + +export interface McpConfig { + type: 'sse' | 'http' + url: string + headers?: Record + credential?: Record +} + +export interface SandboxInstance { + readonly backend: SandboxBackend + readonly id: string + readonly templateId: string + readonly baseUrl: string + readonly meta: Record + readonly mcpConfig?: McpConfig + getAuthHeaders(): Promise> + request(path: string, opts?: RequestInit): Promise +} + +export interface McpDeps { + sandbox: SandboxInstance + getCredentials: () => Promise<{ + cloudbaseEnvId: string + secretId: string + secretKey: string + sessionToken?: string + }> + bashTimeoutMs?: number + workspaceFolderPaths?: string + log?: (msg: string) => void + onArtifact?: (artifact: { + title: string + contentType: 'image' | 'link' | 'json' + data: string + metadata?: Record + }) => void + getMpDeployCredentials?: (appId: string) => Promise<{ appId: string; privateKey: string } | null> + userId?: string + currentModel?: string +} + +export interface MinimalMcpClient { + callTool(req: { name: string; arguments?: unknown }): Promise<{ content?: unknown; isError?: boolean } | unknown> + listTools?(): Promise + close?(): Promise +} + +export interface McpClientBundle { + client: MinimalMcpClient + server: unknown + sdkServer: unknown + close: () => Promise +} + +export interface ToolOverrideHosting { + presignUrl: string + sessionCookie: string + sessionId: string +} + +export interface ToolOverrideConfig { + url: string + headers: Record + modulePath: string + hosting?: ToolOverrideHosting +} + +export interface SandboxProvider { + readonly backend: SandboxBackend + acquire(ctx: AcquireContext, onProgress?: SandboxProgressCallback): Promise + prepare(inst: SandboxInstance, ctx: PrepareContext, onProgress?: SandboxProgressCallback): Promise + release(inst: SandboxInstance, ctx: ReleaseContext): Promise + createMcpClient(deps: McpDeps): Promise + getToolOverrideConfig(inst: SandboxInstance, hosting?: ToolOverrideHosting): Promise + getPreviewBaseUrl(inst: SandboxInstance): Promise + getExisting?(ctx: AcquireContext): Promise + destroy?(inst: SandboxInstance): Promise + deleteConversation?(inst: SandboxInstance, ctx: DeleteConversationContext): Promise +} diff --git a/packages/server/src/sandbox/sandbox-mcp-proxy.ts b/packages/server/src/sandbox/sandbox-mcp-proxy.ts deleted file mode 100644 index 22c823f..0000000 --- a/packages/server/src/sandbox/sandbox-mcp-proxy.ts +++ /dev/null @@ -1,273 +0,0 @@ -/** - * Sandbox MCP Proxy(CodeBuddy SDK runtime 入口) - * - * 创建一对 InMemoryTransport: - * - McpServer 暴露 cloudbase 工具给 agent-sdk - * - Client 由 base-runtime 连接,跑 mcporter 命令到沙箱 - * - * 工具注册、policy 应用、凭证重注入都委托给 lib/cloudbase-mcp.ts。 - * 启动时主动注入一次凭证(mcporter list 需要凭证才能跑)。 - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { SandboxInstance } from './scf-sandbox-manager.js' -import { tool as sdkTool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk' -import { - buildMcporterShellCommand, - createCloudbaseMcpLogger, - createInjectCredentials, - discoverCloudbaseTools as discoverTools, - registerCloudbasePolicies, - registerNoopPlaceholder, - type ToolResult, -} from '../lib/cloudbase-mcp.js' -import { loadAllPolicies } from '../middleware/mcp/cloudbase/_index.js' -import type { CloudbaseExtra } from '../middleware/mcp/cloudbase/_index.js' - -// 启动时加载 policy(异步触发,不阻塞模块导出) -void loadAllPolicies() - -// ─── Types ─────────────────────────────────────────────────────── - -export interface SandboxMcpDeps { - /** SandboxInstance — handles auth headers, scope headers, and request routing */ - sandbox: SandboxInstance - /** 当前会话用户 ID(也用于通过 issueTempCredentials 反查凭证) */ - userId: string - /** 当前会话 envId(用于凭证注入与重注入) */ - envId: string - /** bash 超时 ms,默认 30000 */ - bashTimeoutMs?: number - /** 工作目录(注入给容器) */ - workspaceFolderPaths?: string - /** 日志输出,默认 console.log */ - log?: (msg: string) => void - /** uploadFiles 工具成功返回时的回调,用于触发 artifact 事件 */ - onArtifact?: (artifact: { - title: string - contentType: 'image' | 'link' | 'json' - data: string - metadata?: Record - }) => void - /** 根据 appId 查询小程序部署凭证 */ - getMpDeployCredentials?: (appId: string) => Promise<{ appId: string; privateKey: string } | null> - /** 当前使用的模型 ID */ - currentModel?: string -} - -// ─── Auth Error ────────────────────────────────────────────────── - -class AuthRequiredError extends Error { - constructor(status: number) { - super(`MCP_AUTH_REQUIRED: gateway returned ${status}`) - this.name = 'AuthRequiredError' - } -} - -// ─── Core factory ──────────────────────────────────────────────── - -/** - * 创建沙箱 MCP Server 并通过 InMemoryTransport 返回已连接的 Client。 - * - * - 完全在进程内,零 IPC 开销,无 stdio/子进程 - * - 所有请求通过 SandboxInstance.request(),天然解决 token 过期和 scope header 注入 - * - Server 内部对 gateway 401/403 抛出 AuthRequiredError,Client 侧可感知并重连 - */ -export async function createSandboxMcpClient(deps: SandboxMcpDeps): Promise<{ - client: Client - /** McpServer 实例(@modelcontextprotocol/sdk),供直接操作 */ - server: McpServer - /** SDK MCP Server(@tencent-ai/agent-sdk createSdkMcpServer),传入 query() 的 mcpServers 选项 */ - sdkServer: ReturnType - /** 显式关闭,释放 transport pair */ - close: () => Promise -}> { - const { - sandbox, - userId, - envId, - bashTimeoutMs = 30_000, - workspaceFolderPaths = '', - log = (msg: string) => console.log(msg), - onArtifact, - getMpDeployCredentials, - currentModel: depsCurrentModel, - } = deps - - // ── HTTP helpers ──────────────────────────────────────────────── - // ── HTTP helpers ──────────────────────────────────────────────── - // All requests go through sandbox.request() which injects auth + scope headers. - - // 统一日志:复用流式 sink(行末加 \n 兼容历史调用方) - const logger = createCloudbaseMcpLogger('sandbox-mcp', { - info: (line) => log(line + '\n'), - warn: (line) => log(line + '\n'), - error: (line, err) => log(line + (err ? ' ' + String(err) : '') + '\n'), - }) - - async function apiCall(tool: string, body: unknown, timeoutMs = bashTimeoutMs): Promise { - const res = await sandbox.request(`/api/tools/${tool}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(timeoutMs), - }) - if (res.status === 401 || res.status === 403) { - throw new AuthRequiredError(res.status) - } - const data = (await res.json()) as any - if (!data.success) throw new Error(data.error ?? `${tool} call failed`) - return data.result - } - - async function bashCall(command: string, timeoutMs = bashTimeoutMs): Promise { - return apiCall('bash', { command, timeout: timeoutMs }, timeoutMs) - } - - // 启动时主动注入一次凭证:mcporter list 需要凭证才能跑(SDK runtime 特有)。 - // 运行时的凭证错误重注入由 registerCloudbasePolicies 内部处理(见下方传 envId)。 - const startupInject = createInjectCredentials({ - userId, - envId, - conversationId: sandbox.conversationId, - sandboxFetch: (path, init) => sandbox.request(path, init), - workspaceFolderPaths, - on401: (status) => { - throw new AuthRequiredError(status) - }, - }) - - // ── CloudBase schema discovery + mcporter call(用 lib 共享实现) ─────── - - async function fetchCloudbaseSchema(): Promise { - return discoverTools({ - bash: (cmd, t) => bashCall(cmd, t ?? 20_000), - readJsonFile: async (p) => { - const res = await sandbox.request(`/e2b-compatible/files?path=${encodeURIComponent(p)}`) - if (!res.ok) throw new Error(`Failed to read schema file: ${res.status}`) - return res.json() - }, - }) - } - - async function mcporterCall(toolName: string, args: Record): Promise { - const cmd = buildMcporterShellCommand(toolName, args) - logger.info(`bash cmd: ${cmd}`) - return bashCall(cmd, 60_000) - } - - // ── Inject credentials first, then fetch tools ─────────────── - // Must inject before fetchCloudbaseSchema so mcporter can authenticate - try { - await startupInject() - logger.info('Credentials injected successfully') - } catch (e: any) { - logger.warn(`Failed to inject credentials: ${e.message}`) - } - - // ── Fetch CloudBase tools (degraded on failure) ─────────────── - - let cloudbaseTools: any[] = [] - for (let attempt = 1; attempt <= 3; attempt++) { - try { - cloudbaseTools = await fetchCloudbaseSchema() - logger.info(`Discovered ${cloudbaseTools.length} CloudBase tools (attempt ${attempt})`) - break - } catch (e: any) { - logger.warn(`Schema fetch failed (attempt ${attempt}/3): ${e.message}`) - if (attempt < 3) await new Promise((r) => setTimeout(r, 3_000)) - else logger.warn('Starting in degraded mode (workspace tools only)') - } - } - - // ── Build MCP Server ────────────────────────────────────────── - - const server = new McpServer({ name: 'cloudbase-sandbox-proxy', version: '2.0.0' }) - - // CloudbaseExtra 上下文(注入到所有 policy ctx.extra) - // 注意:injectCredentials 由 registerCloudbasePolicies 内部根据 envId 自动注入到 extra - const sandboxFetch: CloudbaseExtra['sandboxFetch'] = (path, init) => sandbox.request(path, init) - const extra: CloudbaseExtra = { - sandboxUrl: '', // sandbox.request 已内置 baseUrl,policy 通过 sandboxFetch 走 - sandboxAuth: {}, // 同上 - sandboxFetch, - conversationId: sandbox.conversationId, - onArtifact, - getMpDeployCredentials, - currentModel: depsCurrentModel, - } - - const ctxBase = { - userId, - sessionId: sandbox.conversationId ?? '', - extra, - } - - // ── 收集 SDK MCP Server 的 sdkTool 定义(与下方 server.tool 注册同步生成) ─ - const sdkTools: ReturnType[] = [] - - // ── 一站式注册:原生工具 + augmented 工具,同时注册到 server 和 sdkTools ─ - // 凭证错误自动重注入 + ctx.extra.injectCredentials 都由 lib 内部根据 envId 处理。 - await registerCloudbasePolicies({ - nativeTools: cloudbaseTools, - ctxBase, - mcporterCall: async (toolName, args) => { - const result = await mcporterCall(toolName, args) - return (result.output ?? '') as string - }, - envId, - injectOptions: { - workspaceFolderPaths, - on401: (status) => { - throw new AuthRequiredError(status) - }, - }, - register: (name, desc, shape, handler) => { - // 标准 MCP server - server.tool(name, desc, shape as any, handler as any) - // SDK MCP server(agent-sdk 用) - sdkTools.push(sdkTool(name, desc, shape as any, handler as any)) - }, - logger, - }) - - // 若原生工具列表为空,注册占位 tool 让 McpServer 仍声明 tools capability - if (cloudbaseTools.length === 0) { - registerNoopPlaceholder((name, desc, shape, handler) => { - server.tool(name, desc, shape as any, handler as any) - }) - } - - // ── 通过 InMemoryTransport 把 server <-> client 连接起来(进程内零开销) ── - const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair() - await server.connect(serverTransport) - - const client = new Client({ name: 'cloudbase-agent', version: '1.0.0' }) - await client.connect(clientTransport) - - const sdkServer = createSdkMcpServer({ - name: 'cloudbase', - version: '1.0.0', - tools: sdkTools, - }) - - logger.info( - `Ready. sandbox=${sandbox.functionName} session=${sandbox.scfSessionId} scope=${sandbox.conversationId} mode=${sandbox.sandboxMode} coding=${sandbox.isCodingMode} tools=${cloudbaseTools.length}`, - ) - - return { - client, - server, - sdkServer, - close: async () => { - try { - await client.close() - } catch {} - try { - await server.close() - } catch {} - }, - } -} diff --git a/packages/server/src/sandbox/scf-sandbox-manager.ts b/packages/server/src/sandbox/scf-sandbox-manager.ts deleted file mode 100644 index 3f10561..0000000 --- a/packages/server/src/sandbox/scf-sandbox-manager.ts +++ /dev/null @@ -1,723 +0,0 @@ -/** - * SCF Sandbox Manager - * - * Simplified version of scf-sandbox-manager.service.ts - * - No NestJS DI - * - No Rainbow service - uses env vars directly - * - Uses @cloudbase/manager-node for function operations - */ - -import CloudBase from '@cloudbase/manager-node' -import { sign } from '@cloudbase/signature-nodejs' - -// ─── Types ──────────────────────────────────────────────────────────────── - -export type SandboxMode = 'per-conversation' | 'shared' - -export type SandboxProgressCallback = (message: { - phase: 'reuse' | 'create' | 'wait_creating' | 'pull_image' | 'wait_ready' | 'init_mcp' | 'ready' | 'error' - message: string -}) => void - -interface ScfSandboxConfig { - timeoutMs: number - maxCacheSize: number - runtime: string - memory: number - timeout: number - /** SCF Session 最大存活时间(秒)。env: SANDBOX_SESSION_TTL,默认 1800(30 分钟) */ - sessionTTL: number - /** SCF Session 空闲超时(秒)。env: SANDBOX_SESSION_IDLE_TIMEOUT,默认 600(10 分钟) */ - sessionIdleTimeout: number -} - -// ─── SandboxInstance ────────────────────────────────────────────────────── - -export class SandboxInstance { - readonly functionName: string - readonly conversationId: string - /** - * SCF session ID (used as `X-Cloudbase-Session-Id` header for sandbox auth). - * - In `shared` workspaceIsolation mode: equals the user's CloudBase envId - * - In `isolated` mode: equals conversationId - * - May be overridden via `options.sandboxSessionId` in `getOrCreate` - * - * 注意:这不是用户的 CloudBase 环境 ID(之前误命名为 envId 容易引起混淆), - * 实际是沙箱 SCF function 的 session ID。 - */ - readonly scfSessionId: string - readonly sandboxEnvId: string - readonly baseUrl: string - readonly status: 'creating' | 'ready' | 'error' - readonly mode: SandboxMode - /** Workspace isolation mode: 'shared' shares an SCF instance across tasks, 'isolated' is 1:1 */ - readonly sandboxMode: 'shared' | 'isolated' - /** Whether this task is in coding mode (triggers X-Scope-Template: coding) */ - readonly isCodingMode: boolean - - readonly mcpConfig?: { - type: 'sse' | 'http' - url: string - headers?: Record - credential?: { - envId: string - secretId: string - secretKey: string - token: string - } - } - - constructor( - private readonly deps: { - sandboxEnvId: string - getAccessToken: () => Promise - }, - ctx: { - functionName: string - conversationId: string - scfSessionId: string - status: 'creating' | 'ready' | 'error' - mode: SandboxMode - sandboxMode?: 'shared' | 'isolated' - isCodingMode?: boolean - mcpConfig?: SandboxInstance['mcpConfig'] - }, - ) { - this.functionName = ctx.functionName - this.conversationId = ctx.conversationId - this.scfSessionId = ctx.scfSessionId - this.sandboxEnvId = this.deps.sandboxEnvId - this.baseUrl = `https://${this.deps.sandboxEnvId}.api.tcloudbasegateway.com/v1/functions/${ctx.functionName}` - this.status = ctx.status - this.mode = ctx.mode - this.sandboxMode = ctx.sandboxMode || 'isolated' - this.isCodingMode = ctx.isCodingMode || false - this.mcpConfig = ctx.mcpConfig - } - - async getAccessToken(): Promise { - return this.deps.getAccessToken() - } - - static buildAuthHeaders(accessToken: string, scfSessionId: string): Record { - return { - Authorization: `Bearer ${accessToken}`, - 'X-Cloudbase-Session-Id': scfSessionId, - 'X-Tcb-Webfn': 'true', - } - } - - /** - * Build scope headers based on sandbox mode. - * - X-Scope-Id: only sent in 'shared' mode (isolates sub-workspaces within the shared session) - * - X-Scope-Template: sent when in coding mode (triggers template init + vite dev server) - */ - static buildScopeHeaders( - scopeId: string, - sandboxMode: 'shared' | 'isolated', - isCodingMode: boolean, - ): Record { - const headers: Record = {} - if (sandboxMode === 'shared') { - headers['X-Scope-Id'] = scopeId - } - if (isCodingMode) { - headers['X-Scope-Template'] = 'coding' - } - return headers - } - - async getAuthHeaders(): Promise> { - const accessToken = await this.getAccessToken() - return { - ...SandboxInstance.buildAuthHeaders(accessToken, this.scfSessionId), - ...SandboxInstance.buildScopeHeaders(this.conversationId, this.sandboxMode, this.isCodingMode), - } - } - - async getToolOverrideConfig(): Promise<{ url: string; headers: Record }> { - return { - url: this.baseUrl, - headers: await this.getAuthHeaders(), - } - } - - async request(path: string, options: RequestInit = {}): Promise { - return fetch(`${this.baseUrl}${path}`, { - ...options, - headers: { - ...(await this.getAuthHeaders()), - ...(options.headers as Record | undefined), - }, - }) - } -} - -// ─── ScfSandboxManager ──────────────────────────────────────────────────── - -export class ScfSandboxManager { - private readonly config: ScfSandboxConfig = { - timeoutMs: 30 * 60 * 1000, - maxCacheSize: 50, - runtime: 'Nodejs16.13', - memory: 2048, - timeout: 900, - sessionTTL: Number(process.env.SANDBOX_SESSION_TTL || process.env.SCF_SANDBOX_SESSION_TTL) || 1800, - sessionIdleTimeout: - Number(process.env.SANDBOX_SESSION_IDLE_TIMEOUT || process.env.SCF_SANDBOX_SESSION_IDLE_TIMEOUT) || 600, - } - - private cachedAccessToken: { token: string; expiry: number } | null = null - - private getEnvConfig() { - const imageType = process.env.SANDBOX_IMAGE_TYPE || process.env.SCF_SANDBOX_IMAGE_TYPE || 'personal' - const imageConfig: Record = { - ImageType: imageType, - ImageUri: process.env.SANDBOX_IMAGE_URI || process.env.SCF_SANDBOX_IMAGE_URI || '', - ContainerImageAccelerate: - (process.env.SANDBOX_IMAGE_ACCELERATE || process.env.SCF_SANDBOX_IMAGE_ACCELERATE) === 'true', - ImagePort: parseInt(process.env.SANDBOX_IMAGE_PORT || process.env.SCF_SANDBOX_IMAGE_PORT || '9000', 10), - } - - if (imageType === 'enterprise') { - const registryId = process.env.SANDBOX_IMAGE_REGISTRY_ID || '' - if (!registryId) { - throw new Error('Missing SANDBOX_IMAGE_REGISTRY_ID for enterprise sandbox image') - } - imageConfig.RegistryId = registryId - } - - return { - envId: process.env.TCB_ENV_ID || '', - secretId: process.env.TCB_SECRET_ID || '', - secretKey: process.env.TCB_SECRET_KEY || '', - token: process.env.TCB_TOKEN || '', - functionPrefix: process.env.SANDBOX_FUNCTION_PREFIX || process.env.SCF_SANDBOX_FUNCTION_PREFIX || 'sandbox', - imageConfig, - } - } - - private async getAdminAccessToken(): Promise { - // Check cache - if (this.cachedAccessToken && Date.now() < this.cachedAccessToken.expiry) { - return this.cachedAccessToken.token - } - - const envConfig = this.getEnvConfig() - const { secretId, secretKey, token, envId } = envConfig - - if (!secretId || !secretKey || !envId) { - throw new Error('Missing TCB_SECRET_ID, TCB_SECRET_KEY or TCB_ENV_ID') - } - - const host = `${envId}.api.tcloudbasegateway.com` - const url = `https://${host}/auth/v1/token/clientCredential` - const method = 'POST' - - const headers: Record = { - 'Content-Type': 'application/json', - Host: host, - } - - const data = { grant_type: 'client_credentials' } - - const { authorization, timestamp } = sign({ - secretId, - secretKey, - method, - url, - headers, - params: data, - timestamp: Math.floor(Date.now() / 1000) - 1, - withSignedParams: false, - isCloudApi: true, - }) - - headers['Authorization'] = `${authorization}, Timestamp=${timestamp}${token ? `, Token=${token}` : ''}` - headers['X-Signature-Expires'] = '600' - headers['X-Timestamp'] = String(timestamp) - - try { - const res = await fetch(url, { - method, - headers, - body: JSON.stringify(data), - }) - - const body = (await res.json()) as { access_token?: string; expires_in?: number } - const accessToken = body?.access_token - const expiresIn = body?.expires_in || 0 - - if (!accessToken) { - throw new Error('clientCredential response missing access_token') - } - - // Cache for half the expiry time - if (expiresIn) { - this.cachedAccessToken = { - token: accessToken, - expiry: Date.now() + (expiresIn * 1000) / 2, - } - } else { - this.cachedAccessToken = { - token: accessToken, - expiry: Date.now() + 3600 * 1000, - } - } - - return accessToken - } catch (err) { - console.error('[ScfSandbox] getAdminAccessToken failed:', (err as Error).message) - throw err - } - } - - private async buildInstanceDeps() { - const envConfig = this.getEnvConfig() - return { - sandboxEnvId: envConfig.envId, - getAccessToken: () => this.getAdminAccessToken(), - } - } - - private async buildSandboxMcpConfig( - functionName: string, - scfSessionId: string, - conversationId: string, - sandboxEnvId: string, - sandboxMode: 'shared' | 'isolated' = 'shared', - isCodingMode = false, - ): Promise { - const accessToken = await this.getAdminAccessToken() - const url = `https://${sandboxEnvId}.api.tcloudbasegateway.com/v1/functions/${functionName}/mcp` - return { - type: 'http' as const, - url, - headers: { - ...SandboxInstance.buildAuthHeaders(accessToken, scfSessionId), - ...SandboxInstance.buildScopeHeaders(conversationId, sandboxMode, isCodingMode), - }, - } - } - - async getOrCreate( - conversationId: string, - envId: string, - options?: { - mode?: SandboxMode - /** Workspace isolation: 'shared' uses envId as SCF session, 'isolated' uses conversationId */ - workspaceIsolation?: 'shared' | 'isolated' - /** Pre-computed SCF session ID (overrides workspaceIsolation logic) */ - sandboxSessionId?: string - /** Whether this task is in coding mode */ - isCodingMode?: boolean - }, - onProgress?: SandboxProgressCallback, - ): Promise { - const progress = onProgress || (() => {}) - const mode = options?.mode || 'shared' - const isolation = options?.workspaceIsolation || 'shared' - const scfSessionId = options?.sandboxSessionId || (isolation === 'shared' ? envId : conversationId) - const isCodingMode = options?.isCodingMode || false - - const functionName = this.generateFunctionName('shared') - - // Check if function exists - const { exists: functionExists } = await this.checkFunctionExists(functionName) - - if (functionExists) { - progress({ phase: 'reuse', message: '连接已有沙箱...\n' }) - await this.waitForFunctionReady(functionName, undefined, undefined, progress) - const instanceDeps = await this.buildInstanceDeps() - const mcpConfig = await this.buildSandboxMcpConfig( - functionName, - scfSessionId, - conversationId, - instanceDeps.sandboxEnvId, - isolation, - isCodingMode, - ) - - return new SandboxInstance(instanceDeps, { - functionName, - conversationId, - scfSessionId, - status: 'ready', - mode, - sandboxMode: isolation, - isCodingMode, - mcpConfig, - }) - } - - return this.createNewFunction(functionName, conversationId, scfSessionId, mode, options, progress) - } - - /** - * 获取已存在的沙箱实例(不创建新实例) - * 适用于任务删除等场景,沙箱不存在时返回 null - * @param conversationId 会话ID - * @param scfSessionId SCF session ID(shared模式=envId,isolated模式=conversationId) - */ - async getExisting( - conversationId: string, - scfSessionId: string, - options?: { sandboxMode?: 'shared' | 'isolated' | string; isCodingMode?: boolean }, - ): Promise { - const mode = options?.sandboxMode || 'isolated' - const functionName = this.generateFunctionName('shared') - - const { exists } = await this.checkFunctionExists(functionName) - if (!exists) return null - - const instanceDeps = await this.buildInstanceDeps() - return new SandboxInstance(instanceDeps, { - functionName, - conversationId, - scfSessionId, - status: 'ready', - mode: 'shared', - sandboxMode: mode as any, - isCodingMode: options?.isCodingMode || false, - }) - } - - private async createNewFunction( - functionName: string, - conversationId: string, - scfSessionId: string, - mode: SandboxMode, - options?: any, - onProgress?: SandboxProgressCallback, - ): Promise { - const progress = onProgress || (() => {}) - const isolation: 'shared' | 'isolated' = options?.workspaceIsolation || 'shared' - const isCodingMode: boolean = options?.isCodingMode || false - - try { - progress({ phase: 'create', message: '正在创建工作空间...\n' }) - - await this.createFunction(functionName) - - try { - await Promise.all([ - this.waitForFunctionReady(functionName, undefined, undefined, progress), - this.createGatewayApi(functionName), - ]) - } catch (networkError: any) { - console.error(`[ScfSandbox] Network setup failed, rolling back: ${networkError.message}`) - await this.deleteFunction(functionName).catch((delErr) => { - console.warn(`[ScfSandbox] Failed to delete function during rollback: ${delErr.message}`) - }) - throw new Error(`网络配置失败: ${networkError.message}`) - } - - const instanceDeps = await this.buildInstanceDeps() - const mcpConfig = await this.buildSandboxMcpConfig( - functionName, - scfSessionId, - conversationId, - instanceDeps.sandboxEnvId, - isolation, - isCodingMode, - ) - - return new SandboxInstance(instanceDeps, { - functionName, - conversationId, - scfSessionId, - status: 'ready', - mode, - sandboxMode: isolation, - isCodingMode, - mcpConfig, - }) - } catch (error: any) { - console.error(`[ScfSandbox] Creation failed: ${functionName}`) - progress({ phase: 'error', message: `工作空间创建失败: ${error.message}\n` }) - throw new Error(`创建工作空间失败: ${error.message}`) - } - } - - private generateFunctionName(cacheKey: string, prefix?: string): string { - const sanitized = cacheKey.replace(/[^a-zA-Z0-9_-]/g, '-') - return `${prefix || this.getEnvConfig().functionPrefix}-${sanitized}`.substring(0, 60) - } - - private async createFunction(functionName: string): Promise { - const envConfig = this.getEnvConfig() - - try { - const app = new CloudBase({ - secretId: envConfig.secretId, - secretKey: envConfig.secretKey, - token: envConfig.token, - envId: envConfig.envId, - }) - - const createParams = { - FunctionName: functionName, - Namespace: envConfig.envId, - Stamp: 'MINI_QCBASE', - Role: 'TCB_QcsRole', - Code: { - ImageConfig: envConfig.imageConfig, - }, - Type: 'HTTP', - ProtocolType: 'WS', - ProtocolParams: { - WSParams: { - IdleTimeOut: 7200, - }, - }, - MemorySize: this.config.memory, - DiskSize: 1024, - Timeout: this.config.timeout, - InitTimeout: 90, - InstanceConcurrencyConfig: { - MaxConcurrency: 100, - DynamicEnabled: 'FALSE', - InstanceIsolationEnabled: 'TRUE', - Type: 'Session-Based', - SessionConfig: { - SessionSource: 'HEADER', - SessionName: 'X-Cloudbase-Session-Id', - MaximumConcurrencySessionPerInstance: 1, - MaximumTTLInSeconds: this.config.sessionTTL, - MaximumIdleTimeInSeconds: this.config.sessionIdleTimeout, - IdleTimeoutStrategy: 'FATAL', - }, - }, - Environment: { - Variables: [ - ...this.buildGitArchiveVars(), - { Key: 'VITE_DEV_OVERLAY', Value: process.env.VITE_DEV_OVERLAY || '' }, - ], - }, - // VpcConfig: { - // VpcId: '', - // SubnetId: '', - // }, - Description: 'SCF Sandbox for conversation (Image-based)', - } - - await (app.commonService('scf') as any).call({ - Action: 'CreateFunction', - Param: createParams, - }) - } catch (error: any) { - if (error.message?.includes('already exists') || error.code === 'ResourceInUse') { - console.warn(`[ScfSandbox] Function already exists: ${functionName}`) - return - } - throw error - } - } - - private async createGatewayApi(functionName: string): Promise { - const envConfig = this.getEnvConfig() - - try { - const app = new CloudBase({ - secretId: envConfig.secretId, - secretKey: envConfig.secretKey, - token: envConfig.token, - envId: envConfig.envId, - }) - - const domain = `${envConfig.envId}.ap-shanghai.app.tcloudbase.com` - - await (app.commonService() as any).call({ - Action: 'CreateCloudBaseGWAPI', - Param: { - ServiceId: envConfig.envId, - Name: functionName, - Path: `/preview`, - Type: 6, - EnableUnion: true, - AuthSwitch: 2, - PathTransmission: 1, - EnableRegion: true, - Domain: domain, - }, - }) - } catch (error: any) { - if ( - error.message?.includes('already exists') || - error.message?.includes('ResourceInUse') || - error.code === 'ResourceInUse' - ) { - console.warn(`[ScfSandbox] Gateway API already exists: ${functionName}`) - return - } - throw error - } - } - - /** - * 确保该沙箱实例对应的预览网关 API 已注册。 - * 可在 preview-url 接口中调用,保证网关路径可达。 - * 结果按 functionName 缓存,避免每次都调用 CreateCloudBaseGWAPI。 - * - * 参数接受 SandboxInstance 而不是 Task,是因为 functionName 的权威来源 - * 在 SandboxInstance 上(由 getOrCreate 生成并持久化到实例),避免此处 - * 再通过 task.sandboxMode 推算一次、两套生成规则飘移。 - */ - private gatewayEnsuredFunctions = new Set() - - async ensurePreviewGateway(sandbox: SandboxInstance): Promise { - const envConfig = this.getEnvConfig() - const domain = `${envConfig.envId}.service.tcloudbase.com` - const previewBase = `https://${domain}/preview` - - const functionName = sandbox.functionName - if (this.gatewayEnsuredFunctions.has(functionName)) return previewBase - - try { - await this.createGatewayApi(functionName) - console.log(`[ScfSandbox] ensurePreviewGateway: gateway OK (${functionName})`) - } catch (err: any) { - // "api created" / ResourceInUse = already exists, that's fine - if (!err.message?.includes('api created') && !err.message?.includes('ResourceInUse')) { - console.warn(`[ScfSandbox] ensurePreviewGateway: createGatewayApi error: ${err.message}`) - } - } - this.gatewayEnsuredFunctions.add(functionName) - return previewBase - } - - private async checkFunctionExists(functionName: string): Promise<{ exists: boolean; currentImageUri?: string }> { - const envConfig = this.getEnvConfig() - - try { - const app = new CloudBase({ - secretId: envConfig.secretId, - secretKey: envConfig.secretKey, - token: envConfig.token, - envId: envConfig.envId, - }) - - const result = await (app.commonService() as any).call({ - Action: 'GetFunction', - Param: { - FunctionName: functionName, - EnvId: envConfig.envId, - Namespace: envConfig.envId, - ShowCode: 'TRUE', - }, - }) - - if (!result || result.Status === undefined) { - return { exists: false } - } - - const currentImageUri: string | undefined = result.ImageConfig?.ImageUri - return { exists: true, currentImageUri } - } catch { - return { exists: false } - } - } - - private async waitForFunctionReady( - functionName: string, - maxRetries = 120, - retryInterval = 3000, - onProgress?: SandboxProgressCallback, - ): Promise { - const envConfig = this.getEnvConfig() - - const app = new CloudBase({ - secretId: envConfig.secretId, - secretKey: envConfig.secretKey, - token: envConfig.token, - envId: envConfig.envId, - }) - - let progressEmitted = false - for (let i = 0; i < maxRetries; i++) { - try { - const result = await (app.commonService() as any).call({ - Action: 'GetFunction', - Param: { - FunctionName: functionName, - EnvId: envConfig.envId, - Namespace: envConfig.envId, - ShowCode: 'TRUE', - }, - }) - - const status = result?.Status - if (status === 'Active' || status === 'active' || status === 'Running' || status === 'running') { - return - } - if (!progressEmitted) { - progressEmitted = true - onProgress?.({ phase: 'wait_creating', message: '沙箱启动中...\n' }) - } - } catch (error: any) { - if ( - error.code === 'ResourceNotFound' || - error.message?.includes('ResourceNotFound') || - error.message?.includes('not exist') || - error.message?.includes('not found') - ) { - throw new Error(`Function ${functionName} does not exist`) - } - if (i < 5) { - console.warn(`[ScfSandbox] Check function status error: ${error.message}`) - } - } - - await new Promise((resolve) => setTimeout(resolve, retryInterval)) - } - - throw new Error( - `Function ${functionName} not ready after ${maxRetries} retries (${(maxRetries * retryInterval) / 1000}s)`, - ) - } - - private buildGitArchiveVars(): { Key: string; Value: string }[] { - const repo = process.env.GIT_ARCHIVE_REPO - const token = process.env.GIT_ARCHIVE_TOKEN - const user = process.env.GIT_ARCHIVE_USER - - if (!repo || !token) return [] - - return [ - { Key: 'GIT_ARCHIVE_REPO', Value: repo }, - { Key: 'GIT_ARCHIVE_TOKEN', Value: token }, - { Key: 'GIT_ARCHIVE_USER', Value: user || '' }, - ] - } - - private buildGitPersonalVars(): { Key: string; Value: string }[] { - const auth = process.env.GIT_PERSONAL_AUTH - - return [{ Key: 'GIT_PERSONAL_AUTH', Value: auth || '' }] - } - - private async deleteFunction(functionName: string): Promise { - const envConfig = this.getEnvConfig() - - try { - const app = new CloudBase({ - secretId: envConfig.secretId, - secretKey: envConfig.secretKey, - token: envConfig.token, - envId: envConfig.envId, - }) - - await (app.commonService() as any).call({ - Action: 'DeleteFunction', - Param: { - FunctionName: functionName, - Namespace: envConfig.envId, - }, - }) - } catch (error: any) { - console.warn(`[ScfSandbox] Delete function error: ${error.message}`) - } - } -} - -export const scfSandboxManager = new ScfSandboxManager() diff --git a/packages/server/src/sandbox/stateful-custom-configuration.ts b/packages/server/src/sandbox/stateful-custom-configuration.ts new file mode 100644 index 0000000..d6295e1 --- /dev/null +++ b/packages/server/src/sandbox/stateful-custom-configuration.ts @@ -0,0 +1,44 @@ +/** + * Helpers for StartSandboxInstance / UpdateSandboxTool CustomConfiguration. + * Production injects GIT_ARCHIVE via workspace/env after health — not at Start. + * Describe may return read-only fields (e.g. ImageDigest) that Start rejects. + */ + +export type StatefulEnvVar = { Name: string; Value: string } + +const START_CUSTOM_CONFIGURATION_KEYS = [ + 'Image', + 'ImageRegistryType', + 'Command', + 'Ports', + 'Resources', + 'Probe', + 'Env', +] as const + +/** Tool template fields accepted by StartSandboxInstance (no Env). */ +export function pickStartCustomConfigurationFromTool( + toolCustomConfiguration: Record, +): Record { + return pickStartCustomConfigurationFields(toolCustomConfiguration) +} + +function pickStartCustomConfigurationFields(source: Record): Record { + const out: Record = {} + for (const key of START_CUSTOM_CONFIGURATION_KEYS) { + if (key in source && source[key] !== undefined) { + out[key] = source[key] + } + } + return out +} + +/** Merge instance env into tool template fields accepted by StartSandboxInstance. */ +export function mergeInstanceEnvIntoToolConfiguration( + toolCustomConfiguration: Record, + instanceEnv: StatefulEnvVar[], +): Record { + const base = pickStartCustomConfigurationFields(toolCustomConfiguration) + if (!instanceEnv.length) return base + return { ...base, Env: instanceEnv } +} diff --git a/packages/server/src/sandbox/stateful-sandbox-auth.ts b/packages/server/src/sandbox/stateful-sandbox-auth.ts new file mode 100644 index 0000000..ee576f5 --- /dev/null +++ b/packages/server/src/sandbox/stateful-sandbox-auth.ts @@ -0,0 +1,26 @@ +/** + * AGS instance auth: TCB gateway key + optional instance access token (X-Access-Token). + * + * ENABLE_AUTH_MODE=false (default): StartSandboxInstance AuthMode NONE, no X-Access-Token. + * ENABLE_AUTH_MODE=true: requires TCB_ACCESS_TOKEN (sit_*); gateway requests include X-Access-Token. + */ + +export function isStatefulAuthModeEnabled(): boolean { + return (process.env.ENABLE_AUTH_MODE || '').toLowerCase() === 'true' +} + +export function getTcbAccessToken(): string { + return process.env.TCB_ACCESS_TOKEN?.trim() || '' +} + +/** Fail fast when auth is on but instance token is missing. */ +export function assertStatefulSandboxAuthConfig(): void { + if (isStatefulAuthModeEnabled() && !getTcbAccessToken()) { + throw new Error('ENABLE_AUTH_MODE=true requires TCB_ACCESS_TOKEN (instance sit_* for X-Access-Token)') + } +} + +/** Injected into sandbox business image via PUT /api/workspace/env (not at Start boot). */ +export function buildStatefulWorkspaceAuthEnv(): Record { + return { ENABLE_AUTH_MODE: isStatefulAuthModeEnabled() ? 'true' : 'false' } +} diff --git a/packages/server/src/sandbox/stateful-sandbox-ttl.ts b/packages/server/src/sandbox/stateful-sandbox-ttl.ts new file mode 100644 index 0000000..83506b3 --- /dev/null +++ b/packages/server/src/sandbox/stateful-sandbox-ttl.ts @@ -0,0 +1,29 @@ +/** + * AGS sandbox instance TTL (StartSandboxInstance.Timeout + CreateSandboxTool.DefaultTimeout). + * + * SANDBOX_TTL_SECONDS — positive integer seconds (default 1800 = 30m). + * Distinct from task.maxDuration / MAX_SANDBOX_DURATION (product limit on tasks; not wired to AGS stop). + */ + +export const DEFAULT_SANDBOX_TTL_SECONDS = 30 * 60 + +export function resolveSandboxTtlSeconds(): number { + const raw = process.env.SANDBOX_TTL_SECONDS?.trim() + if (!raw) return DEFAULT_SANDBOX_TTL_SECONDS + const seconds = Number.parseInt(raw, 10) + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new Error('SANDBOX_TTL_SECONDS must be a positive integer (seconds)') + } + return seconds +} + +/** AGS duration string (e.g. 30m, 1h, 90s). */ +export function formatAgsSandboxTimeout(seconds: number): string { + if (seconds >= 3600 && seconds % 3600 === 0) return `${seconds / 3600}h` + if (seconds >= 60 && seconds % 60 === 0) return `${seconds / 60}m` + return `${seconds}s` +} + +export function resolveAgsSandboxTimeout(): string { + return formatAgsSandboxTimeout(resolveSandboxTtlSeconds()) +} diff --git a/packages/server/src/sandbox/stateful-tool-warmup.ts b/packages/server/src/sandbox/stateful-tool-warmup.ts new file mode 100644 index 0000000..81f8761 --- /dev/null +++ b/packages/server/src/sandbox/stateful-tool-warmup.ts @@ -0,0 +1,57 @@ +/** + * AGS tool image pull window after CreateSandboxTool / UpdateSandboxTool. + * See 一条龙.md pitfall #24: immediate StartSandboxInstance → InternalError (~37s). + */ + +import type { SandboxProgressCallback } from './provider/types.js' +import { isAgsRetryableError } from './ags-error.js' + +const WARMUP_POLL_MS = 10_000 +const WARMUP_POLL_MAX = 6 + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)) +} + +function emitToolWarmupProgress(onProgress: SandboxProgressCallback | undefined): void { + onProgress?.({ + phase: 'template_warmup', + message: '沙箱模板预热中(云平台拉取镜像)...\n', + }) +} + +function emitInstanceStartProgress(onProgress: SandboxProgressCallback | undefined): void { + onProgress?.({ + phase: 'pull_image', + message: '沙箱实例启动中(镜像拉取或就绪重试)...\n', + }) +} + +/** After CreateSandboxTool: short poll window before first StartSandboxInstance. */ +export async function waitStatefulToolImageWarmup(onProgress?: SandboxProgressCallback): Promise { + for (let round = 1; round <= WARMUP_POLL_MAX; round++) { + emitToolWarmupProgress(onProgress) + await sleep(WARMUP_POLL_MS) + } +} + +/** Start instance; on InternalError poll 10s × up to 6 attempts (一条龙 #24). */ +export async function startStatefulInstanceWithWarmup( + start: () => Promise, + onProgress?: SandboxProgressCallback, +): Promise { + let lastErr: unknown + for (let attempt = 1; attempt <= WARMUP_POLL_MAX; attempt++) { + try { + return await start() + } catch (err) { + lastErr = err + if (!isAgsRetryableError(err) || attempt >= WARMUP_POLL_MAX) { + throw err + } + emitInstanceStartProgress(onProgress) + await sleep(WARMUP_POLL_MS) + } + } + throw lastErr +} diff --git a/packages/server/src/sandbox/stateful-vibecoding-image.ts b/packages/server/src/sandbox/stateful-vibecoding-image.ts new file mode 100644 index 0000000..1b9bdf5 --- /dev/null +++ b/packages/server/src/sandbox/stateful-vibecoding-image.ts @@ -0,0 +1,92 @@ +/** + * Vibecoding sandbox image resolution for sandbox infra CreateSandboxTool / UpdateSandboxTool. + * + * Default: team public TCR (开箱即用). Override with STATEFUL_SANDBOX_IMAGE or tenant TCR_IMAGE. + */ + +import { existsSync, readFileSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** Public CCR namespace for OpenVibeCoding / vibecoding (see code_sandbox/一条龙.md §账号与 CCR). */ +export const VIBECODING_PUBLIC_TCR_REGISTRY = 'ccr.ccs.tencentyun.com' +export const VIBECODING_PUBLIC_TCR_NAMESPACE = 'tcb-sandbox-public-cbe88d' + +/** Repository name under public namespace (一条龙 CCR repo 名一般为 tcb-sandbox-ags;公开 ns 用团队约定名). */ +export const VIBECODING_PUBLIC_TCR_REPO = + process.env.STATEFUL_PUBLIC_TCR_REPOSITORY?.trim() || 'tcb-sandbox-public-cbe88d' + +/** Default tag when URI has no `:tag` (一条龙格式 YYMMDD-HHMM-…-vibecoding). */ +export const VIBECODING_PUBLIC_TCR_DEFAULT_TAG = + process.env.STATEFUL_SANDBOX_IMAGE_TAG?.trim() || '260526-1008-vibecoding' + +/** GHCR source used by pnpm setup:tcr before pushing to tenant TCR. */ +export const VIBECODING_GHCR_IMAGE = 'ghcr.io/yhsunshining/cloudbase-workspace:260515-01342a05' + +export function buildPublicVibecodingTcrImage(tag: string = VIBECODING_PUBLIC_TCR_DEFAULT_TAG): string { + return `${VIBECODING_PUBLIC_TCR_REGISTRY}/${VIBECODING_PUBLIC_TCR_NAMESPACE}/${VIBECODING_PUBLIC_TCR_REPO}:${tag}` +} + +/** Team public vibecoding image on TCR (no per-tenant setup:tcr required for first CreateTool). */ +export const DEFAULT_STATEFUL_SANDBOX_IMAGE = buildPublicVibecodingTcrImage() + +const REPO_ROOT_ENV_LOCAL = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../.env.local') + +let cachedEnvLocal: Record | null = null + +function loadRepoRootEnvLocal(): Record { + if (cachedEnvLocal) return cachedEnvLocal + cachedEnvLocal = {} + if (!existsSync(REPO_ROOT_ENV_LOCAL)) return cachedEnvLocal + for (const line of readFileSync(REPO_ROOT_ENV_LOCAL, 'utf8').split('\n')) { + const t = line.trim() + if (!t || t.startsWith('#')) continue + const eq = t.indexOf('=') + if (eq <= 0) continue + const k = t.slice(0, eq).trim() + const v = t.slice(eq + 1).trim() + if (k) cachedEnvLocal[k] = v + } + return cachedEnvLocal +} + +/** Append :tag when env value is registry/namespace/repo only. */ +function normalizeImageUri(uri: string): string { + const t = uri.trim() + if (!t) return '' + if (t.includes('@')) return t + const lastSlash = t.lastIndexOf('/') + const repoPart = lastSlash >= 0 ? t.slice(lastSlash + 1) : t + if (!repoPart.includes(':')) { + return `${t}:${VIBECODING_PUBLIC_TCR_DEFAULT_TAG}` + } + return t +} + +/** Image URI for CreateSandboxTool: explicit env → tenant TCR → public TCR default. */ +export function resolveStatefulSandboxImage(): string { + const fromProcess = process.env.STATEFUL_SANDBOX_IMAGE?.trim() || process.env.TCR_IMAGE?.trim() || '' + if (fromProcess) return normalizeImageUri(fromProcess) + + const local = loadRepoRootEnvLocal() + const fromLocal = local.STATEFUL_SANDBOX_IMAGE?.trim() || local.TCR_IMAGE?.trim() || '' + if (fromLocal) return normalizeImageUri(fromLocal) + + return DEFAULT_STATEFUL_SANDBOX_IMAGE +} + +/** Sandbox infra ImageRegistryType for CustomConfiguration.Image. */ +export function resolveStatefulImageRegistryType(image: string): string { + const explicit = process.env.STATEFUL_IMAGE_REGISTRY?.trim() + if (explicit) return explicit + if (image.includes('ccr.ccs.tencentyun.com')) return 'personal' + return 'personal' +} + +export function formatMissingStatefulSandboxImageError(): string { + return [ + 'Missing STATEFUL_SANDBOX_IMAGE (vibecoding image URI for sandbox infra CreateSandboxTool).', + `Expected public default: ${DEFAULT_STATEFUL_SANDBOX_IMAGE}`, + 'Set STATEFUL_SANDBOX_IMAGE or run pnpm setup:tcr for a private TCR copy.', + ].join(' ') +} diff --git a/packages/server/src/sandbox/stateful/e2b-native-client.ts b/packages/server/src/sandbox/stateful/e2b-native-client.ts new file mode 100644 index 0000000..6375051 --- /dev/null +++ b/packages/server/src/sandbox/stateful/e2b-native-client.ts @@ -0,0 +1,124 @@ +import { Sandbox } from 'e2b' +import type { SandboxInstance } from '../provider/types.js' + +const ENVD_PORT = '49983' +const WORKSPACE_ROOT = '/home/user' +const DEFAULT_ENVD_VERSION = process.env.TCB_ENVD_VERSION || '99.99.99' + +/** Map UI/API paths to envd-relative paths (cwd defaults to /home/user). */ +export function resolveStatefulFilePath(filePath: string): string { + const p = (filePath || '').trim().replace(/\\/g, '/') + if (!p) return p + if (p.startsWith(`${WORKSPACE_ROOT}/`)) return p.slice(WORKSPACE_ROOT.length + 1) + if (p === WORKSPACE_ROOT) return '.' + // Legacy: file-content strips leading "/" only → "/home/user/x" becomes "home/user/x" + if (p.startsWith('home/user/')) return p.slice('home/user/'.length) + if (p.startsWith('/')) return p.slice(1) + return p +} + +interface NativeCommandResult { + exitCode: number + stdout: string + stderr: string +} + +function createHeaderInjector(headers: Record) { + return { + onRequest({ request }: { request: Request }) { + const merged = new Headers(request.headers) + for (const [key, value] of Object.entries(headers)) { + merged.set(key, value) + } + return new Request(request, { headers: merged }) + }, + } +} + +export async function createStatefulNativeE2bClient(inst: SandboxInstance): Promise { + if (inst.backend !== 'stateful') { + throw new Error('createStatefulNativeE2bClient only supports stateful sandbox instances') + } + + const baseHeaders = await inst.getAuthHeaders() + const auth = baseHeaders['X-Cloudbase-Authorization'] + const routingHeaders: Record = { + 'E2b-Sandbox-Id': inst.id, + 'E2b-Sandbox-Port': ENVD_PORT, + } + + // Match e2b-example: auth on Sandbox ctor; routing headers on envd HTTP clients. + const sdkSandbox = new Sandbox({ + sandboxId: inst.id, + envdVersion: DEFAULT_ENVD_VERSION, + sandboxUrl: inst.baseUrl, + headers: auth ? { 'X-Cloudbase-Authorization': auth } : {}, + } as any) as any + + // files.* HTTP client does not always inherit ctor headers — mirror e2b-example/files middleware. + const filesHeaders: Record = { + ...(auth ? { 'X-Cloudbase-Authorization': auth } : {}), + ...routingHeaders, + } + sdkSandbox.files?.envdApi?.api?.use?.(createHeaderInjector(filesHeaders)) + + const routingInjector = createHeaderInjector(routingHeaders) + ;[sdkSandbox.commands?.envdApi?.api, sdkSandbox.pty?.envdApi?.api, sdkSandbox.git?.envdApi?.api].forEach((api) => + api?.use?.(routingInjector), + ) + + return sdkSandbox +} + +export async function statefulReadTextFile(inst: SandboxInstance, filePath: string): Promise { + const sdk = await createStatefulNativeE2bClient(inst) + const normalized = resolveStatefulFilePath(filePath) + try { + return (await sdk.files.read(normalized)) as string + } catch { + return null + } +} + +export async function statefulReadBinaryFile(inst: SandboxInstance, filePath: string): Promise { + const sdk = await createStatefulNativeE2bClient(inst) + const normalized = resolveStatefulFilePath(filePath) + try { + return (await sdk.files.read(normalized, { format: 'bytes' })) as Uint8Array + } catch { + return null + } +} + +export async function statefulWriteTextFile( + inst: SandboxInstance, + filePath: string, + content: string, +): Promise { + const sdk = await createStatefulNativeE2bClient(inst) + const normalized = resolveStatefulFilePath(filePath) + try { + await sdk.files.write(normalized, content) + return true + } catch { + return false + } +} + +export async function statefulRunCommand(inst: SandboxInstance, command: string): Promise { + const sdk = await createStatefulNativeE2bClient(inst) + try { + const result = await sdk.commands.run(command).catch((err: any) => err) + return { + exitCode: typeof result?.exitCode === 'number' ? result.exitCode : 1, + stdout: typeof result?.stdout === 'string' ? result.stdout : '', + stderr: typeof result?.stderr === 'string' ? result.stderr : '', + } + } catch (err) { + return { + exitCode: 1, + stdout: '', + stderr: err instanceof Error ? err.message : 'Command execution failed', + } + } +} diff --git a/packages/server/src/sandbox/stateful/gateway.ts b/packages/server/src/sandbox/stateful/gateway.ts new file mode 100644 index 0000000..89dc3a1 --- /dev/null +++ b/packages/server/src/sandbox/stateful/gateway.ts @@ -0,0 +1,110 @@ +/** + * TCB data-plane gateway helpers for stateful AGS instances. + * + * Browser iframes cannot send X-Cloudbase-Authorization / E2b-* headers; 使用 OpenVibeCoding + * preview proxy or AGS direct URLs for UI. Server-side code uses these helpers. + */ + +import { resolveStatefulGatewayUrl } from '../ensure-stateful-tool.js' + +export const SANDBOX_BUSINESS_IMAGE_PORT = 9000 +export const ENVD_PORT = 49983 + +export interface StatefulGatewayTarget { + /** Gateway base, e.g. https://{envId}.api.tcloudbasegateway.com/v1/sandbox/- */ + baseUrl: string + sandboxId: string + /** Container port routed via E2b-Sandbox-Port (must be declared on the AGS tool). */ + port: number + /** Path on the target service (leading slash). For 沙箱业务镜像 preview use `/preview/{port}/...`. */ + path: string + headers: Record + url: string +} + +export function buildDataPlaneHeaders(opts: { + tcbApiKey: string + sandboxId: string + port: number + /** Instance sit_* when ENABLE_AUTH_MODE=true (TCB_ACCESS_TOKEN). */ + accessToken?: string +}): Record { + const headers: Record = { + 'X-Cloudbase-Authorization': `Bearer ${opts.tcbApiKey}`, + 'E2b-Sandbox-Id': opts.sandboxId, + 'E2b-Sandbox-Port': String(opts.port), + } + const token = opts.accessToken?.trim() + if (token) headers['X-Access-Token'] = token + return headers +} + +/** + * Build a gateway request target for a specific container port. + * + * - port 9000: 沙箱业务镜像 HTTP (paths like /health, /preview/5173/, /api/tools/bash) + * - other ports: must appear in CreateSandboxTool CustomConfiguration.Ports or gateway returns 500 + */ +export function buildGatewayTarget(args: { + envId: string + sandboxId: string + tcbApiKey: string + port: number + path?: string + gatewayBaseUrl?: string + accessToken?: string +}): StatefulGatewayTarget { + const baseUrl = (args.gatewayBaseUrl || resolveStatefulGatewayUrl(args.envId)).replace(/\/$/, '') + const path = normalizePath(args.path ?? '/') + const headers = buildDataPlaneHeaders({ + tcbApiKey: args.tcbApiKey, + sandboxId: args.sandboxId, + port: args.port, + accessToken: args.accessToken, + }) + return { + baseUrl, + sandboxId: args.sandboxId, + port: args.port, + path, + headers, + url: `${baseUrl}${path}`, + } +} + +/** 沙箱业务镜像 reverse-proxy path for a dev server port (always via 沙箱业务镜像 :9000). */ +export function buildTrwPreviewPath(vitePort: number, subpath = '/'): string { + const suffix = subpath.startsWith('/') ? subpath : `/${subpath}` + return `/preview/${vitePort}${suffix === '/' ? '/' : suffix}` +} + +export function buildTrwPreviewGatewayTarget(args: { + envId: string + sandboxId: string + tcbApiKey: string + vitePort: number + subpath?: string + gatewayBaseUrl?: string + accessToken?: string +}): StatefulGatewayTarget { + return buildGatewayTarget({ + ...args, + port: SANDBOX_BUSINESS_IMAGE_PORT, + path: buildTrwPreviewPath(args.vitePort, args.subpath), + }) +} + +export async function gatewayFetch(target: StatefulGatewayTarget, init?: RequestInit): Promise { + return fetch(target.url, { + ...init, + headers: { + ...target.headers, + ...((init?.headers as Record | undefined) ?? {}), + }, + }) +} + +function normalizePath(path: string): string { + if (!path || path === '/') return '/' + return path.startsWith('/') ? path : `/${path}` +} diff --git a/packages/server/src/sandbox/stateful/stateful-mcp-client.ts b/packages/server/src/sandbox/stateful/stateful-mcp-client.ts new file mode 100644 index 0000000..77b5458 --- /dev/null +++ b/packages/server/src/sandbox/stateful/stateful-mcp-client.ts @@ -0,0 +1,740 @@ +/** + * Stateful sandbox MCP client + * + * 沙箱业务镜像 for_vibecoding / vibecoding preset data plane. + * Protocol: + * - PUT /api/workspace/env inject credentials (NOT /api/session/env) + * - POST /api/tools/{tool} tool execution + * - mcporter in vibecoding image (/opt/cloudbase-mcp) + * + * Shared workspace at /home/user (no scope headers). + * Miniprogram: POST /api/jobs/miniprogram-deploy (enabled by default). + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { tool as sdkTool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk' +import { z } from 'zod' +import { nanoid } from 'nanoid' +import cron from 'node-cron' + +import type { McpClientBundle, McpDeps } from '../provider/types.js' +import { + miniprogramStartToJson, + pollTrwMiniprogramJob, + startTrwMiniprogramDeploy, + type MiniprogramDeployRequest, +} from '../trw-miniprogram-client.js' +import { getDb } from '../../db/index.js' +import { scheduleTask, unscheduleTask } from '../../services/cron-scheduler.js' + +// ─── Auth Error ────────────────────────────────────────────────── + +class AuthRequiredError extends Error { + constructor(status: number) { + super(`MCP_AUTH_REQUIRED: gateway returned ${status}`) + this.name = 'AuthRequiredError' + } +} + +/** mcporter may write stderr into the file when invoked with `2>&1`; strip to first JSON token. */ +export function parseMcporterSchemaContent(raw: string): { tools: unknown[] } { + const trimmed = raw.trim() + const start = trimmed.search(/[\[{]/) + if (start < 0) throw new Error('No JSON in schema output') + const parsed = JSON.parse(trimmed.slice(start)) as { tools?: unknown[] } | unknown[] + if (Array.isArray(parsed)) return { tools: parsed } + if (Array.isArray(parsed.tools)) return { tools: parsed.tools } + throw new Error('No tools array in schema response') +} + +// ─── JSON Schema → Zod ─────────────────────────────────────────── + +function jsonSchemaToZodRawShape(schema: any): Record { + if (!schema || schema.type !== 'object' || !schema.properties) return {} + const shape: Record = {} + const required = new Set(schema.required || []) + for (const [key, propSchema] of Object.entries(schema.properties)) { + let zodType = jsonSchemaPropertyToZod(propSchema as any) + if (!required.has(key)) zodType = zodType.optional() + shape[key] = zodType + } + return shape +} + +function jsonSchemaPropertyToZod(propSchema: any): z.ZodTypeAny { + if (!propSchema) return z.any() + const { type, description, enum: enumValues, items, properties, required } = propSchema + let zodType: z.ZodTypeAny + if (enumValues && Array.isArray(enumValues)) { + zodType = z.enum(enumValues as [string, ...string[]]) + } else if (type === 'string') { + zodType = z.string() + } else if (type === 'number' || type === 'integer') { + zodType = z.number() + } else if (type === 'boolean') { + zodType = z.boolean() + } else if (type === 'array') { + const itemType = items ? jsonSchemaPropertyToZod(items) : z.any() + zodType = z.array(itemType) + } else if (type === 'object') { + if (properties) { + const shape: Record = {} + const reqSet = new Set(required || []) + for (const [k, v] of Object.entries(properties)) { + let propType = jsonSchemaPropertyToZod(v as any) + if (!reqSet.has(k)) propType = propType.optional() + shape[k] = propType + } + zodType = z.object(shape) + } else { + zodType = z.record(z.string(), z.any()) + } + } else { + zodType = z.any() + } + if (description) zodType = zodType.describe(description) + return zodType +} + +// ─── Helpers ───────────────────────────────────────────────────── + +function isFilePath(localPath: string): boolean { + const basename = localPath.replace(/\/+$/, '').split('/').pop() || '' + return /\.[a-zA-Z0-9]+$/.test(basename) +} + +function extractDeployUrl(rawText: string, isFile = false, depth = 0): string | null { + if (depth > 5) return null + try { + const parsed = JSON.parse(rawText) + if (Array.isArray(parsed)) { + const firstText = parsed[0]?.text + if (typeof firstText === 'string') return extractDeployUrl(firstText, isFile, depth + 1) + return null + } + if (typeof parsed !== 'object' || parsed === null) return null + if (parsed.accessUrl) { + const url = new URL(parsed.accessUrl) + if (!isFile && url.pathname !== '/' && !url.pathname.endsWith('/')) url.pathname += '/' + if (!url.searchParams.get('t')) url.searchParams.set('t', String(Date.now())) + return url.toString() + } + if (parsed.staticDomain) return `https://${parsed.staticDomain}/?t=${Date.now()}` + const innerText = parsed?.res?.content?.[0]?.text || parsed?.content?.[0]?.text + if (typeof innerText === 'string') return extractDeployUrl(innerText, isFile, depth + 1) + } catch { + // ignore + } + return null +} + +function isCredentialError(output: string): boolean { + return ( + output.includes('AUTH_REQUIRED') || + output.includes('The SecretId is not found') || + output.includes('SecretId is not found') || + output.includes('InvalidParameter.SecretIdNotFound') || + output.includes('AuthFailure') + ) +} + +function serializeFnCall(toolName: string, args: Record): string { + if (!args || Object.keys(args).length === 0) return `cloudbase.${toolName}()` + const parts = Object.entries(args) + .map(([k, v]) => { + if (v === undefined || v === null) return null + if (typeof v === 'string') return `${k}: ${JSON.stringify(v)}` + if (typeof v === 'boolean' || typeof v === 'number') return `${k}: ${v}` + return `${k}: ${JSON.stringify(v)}` + }) + .filter(Boolean) + .join(', ') + return `cloudbase.${toolName}(${parts})` +} + +// ─── MCP Client factory ────────────────────────────────────────── + +async function resolveMiniprogramPrivateKey( + appId: string, + getMpDeployCredentials: McpDeps['getMpDeployCredentials'], +): Promise { + if (!getMpDeployCredentials) return undefined + const creds = await getMpDeployCredentials(appId) + return creds?.privateKey +} + +function mcpTextPayload(text: string, isError = false) { + return isError + ? { content: [{ type: 'text' as const, text }], isError: true as const } + : { content: [{ type: 'text' as const, text }] } +} + +async function runStatefulPublishMiniprogram( + args: Record, + http: (path: string, init?: RequestInit) => Promise, + getMpDeployCredentials: McpDeps['getMpDeployCredentials'], +) { + const appId = args.appId as string + const privateKey = await resolveMiniprogramPrivateKey(appId, getMpDeployCredentials) + if (!privateKey) { + return mcpTextPayload( + JSON.stringify({ + error: true, + message: `未找到 appId ${appId} 的部署密钥,请先在小程序管理中关联该 appId`, + }), + true, + ) + } + + const req: MiniprogramDeployRequest = { + appid: appId, + privateKey, + action: args.action as 'preview' | 'upload', + projectPath: args.projectPath as string, + version: args.version as string | undefined, + description: args.description as string | undefined, + robot: args.robot as number | undefined, + } + + const outcome = await startTrwMiniprogramDeploy(http, req) + const text = miniprogramStartToJson(outcome) + if (!outcome.ok) return mcpTextPayload(text, true) + if (outcome.envelope.async) return mcpTextPayload(text) + if (!outcome.envelope.success) return mcpTextPayload(text, true) + return mcpTextPayload(text) +} + +async function runStatefulDeployJobStatus( + jobId: string, + http: (path: string, init?: RequestInit) => Promise, +) { + const polled = await pollTrwMiniprogramJob(http, jobId) + return mcpTextPayload(JSON.stringify(polled.body ?? { error: true, status: polled.httpStatus })) +} + +export async function createStatefulMcpClient(deps: McpDeps): Promise { + const { + sandbox, + getCredentials, + bashTimeoutMs = 30_000, + workspaceFolderPaths = '', + log = (msg: string) => console.log(msg), + onArtifact, + getMpDeployCredentials, + userId: depsUserId, + currentModel: depsCurrentModel, + } = deps + + // ── HTTP helpers via sandbox.request (auth + routing auto-injected) ── + + async function apiCall(tool: string, body: unknown, timeoutMs = bashTimeoutMs): Promise { + const res = await sandbox.request(`/api/tools/${tool}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }) + if (res.status === 401 || res.status === 403) throw new AuthRequiredError(res.status) + const data = (await res.json()) as any + if (!data.success) throw new Error(data.error ?? `${tool} call failed`) + return data.result + } + + async function bashCall(command: string, timeoutMs = bashTimeoutMs): Promise { + return apiCall('bash', { command, timeout: timeoutMs }, timeoutMs) + } + + // AGS-specific: credentials go to /api/workspace/env (PUT, flat KV body). + async function injectCredentials(): Promise { + const creds = await getCredentials() + const res = await sandbox.request('/api/workspace/env', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + CLOUDBASE_ENV_ID: creds.cloudbaseEnvId, + TENCENTCLOUD_SECRETID: creds.secretId, + TENCENTCLOUD_SECRETKEY: creds.secretKey, + TENCENTCLOUD_SESSIONTOKEN: creds.sessionToken ?? '', + INTEGRATION_IDE: 'codebuddy', + WORKSPACE_FOLDER_PATHS: workspaceFolderPaths, + }), + }) + if (res.status === 401 || res.status === 403) throw new AuthRequiredError(res.status) + const data = (await res.json()) as any + if (!data.success) throw new Error(`Failed to inject credentials: ${data.error}`) + } + + async function fetchCloudbaseSchema(): Promise { + const tmpPath = `.mcporter-schema.json` + await bashCall(`mcporter list cloudbase --schema --output json > ${tmpPath}`, 20_000) + const res = await sandbox.request('/api/tools/read', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: tmpPath }), + }) + if (!res.ok) throw new Error(`Failed to read schema file: ${res.status}`) + const data = (await res.json()) as { success?: boolean; result?: { content?: string } } + if (!data.success || !data.result?.content) throw new Error('Empty schema file') + const { tools } = parseMcporterSchemaContent(data.result.content) + return tools as any[] + } + + async function mcporterCall(toolName: string, args: Record): Promise { + const expr = serializeFnCall(toolName, args) + const escaped = expr.replace(/'/g, "'\\''") + const cmd = `mcporter call '${escaped}' 2>&1` + log(`[stateful-mcp] bash cmd: ${cmd}\n`) + return bashCall(cmd, 60_000) + } + + // ── Inject credentials before fetching tools ── + try { + await injectCredentials() + log(`[stateful-mcp] Credentials injected successfully\n`) + } catch (e: any) { + log(`[stateful-mcp] Failed to inject credentials: ${e.message}\n`) + } + + // ── Fetch CloudBase tool schema (degraded on failure) ── + let cloudbaseTools: any[] = [] + for (let attempt = 1; attempt <= 3; attempt++) { + try { + cloudbaseTools = await fetchCloudbaseSchema() + log(`[stateful-mcp] Discovered ${cloudbaseTools.length} CloudBase tools (attempt ${attempt})\n`) + break + } catch (e: any) { + log(`[stateful-mcp] Schema fetch failed (attempt ${attempt}/3): ${e.message}\n`) + if (attempt < 3) await new Promise((r) => setTimeout(r, 3_000)) + else log(`[stateful-mcp] Starting in degraded mode (workspace tools only)\n`) + } + } + + // ── Build MCP Server ── + const server = new McpServer({ name: 'stateful-cloudbase-sandbox-proxy', version: '1.0.0' }) + const SKIP = new Set(['logout', 'interactiveDialog']) + + for (const tool of cloudbaseTools) { + if (SKIP.has(tool.name)) continue + + if (tool.name === 'login') { + server.tool( + 'login', + 'Re-authenticate CloudBase credentials for this workspace session. No parameters needed.', + {}, + async () => { + try { + await injectCredentials() + return { content: [{ type: 'text' as const, text: JSON.stringify({ ok: true }) }] } + } catch (e: any) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ ok: false, message: e.message }) }], + isError: true, + } + } + }, + ) + continue + } + + const zodShape = jsonSchemaToZodRawShape(tool.inputSchema) + server.tool( + tool.name, + (tool.description ?? `CloudBase tool: ${tool.name}`) + + '\n\nNOTE: localPath refers to paths inside the container workspace.', + zodShape as any, + async (args: Record) => { + if (tool.name === 'auth' && args?.action === 'start_auth') { + try { + await injectCredentials() + return { + content: [ + { type: 'text' as const, text: JSON.stringify({ ok: true, message: 'Credentials refreshed' }) }, + ], + } + } catch (e: any) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ ok: false, message: e.message }) }], + isError: true, + } + } + } + + if (tool.name === 'downloadTemplate') args = { ...args, ide: 'codebuddy' } + + const attemptCall = async () => { + const result = await mcporterCall(tool.name, args) + return result.output ?? '' + } + + try { + let output = await attemptCall() + if (isCredentialError(output)) { + log(`[stateful-mcp] Credential error for ${tool.name}, re-injecting...\n`) + await injectCredentials() + output = await attemptCall() + if (isCredentialError(output)) { + return { + content: [ + { + type: 'text' as const, + text: output + '\n\nCredential re-injection attempted but error persists.', + }, + ], + isError: true, + } + } + } + return { content: [{ type: 'text' as const, text: output }] } + } catch (e: any) { + return { + content: [{ type: 'text' as const, text: `Error: ${e.message}` }], + isError: true, + } + } + }, + ) + } + + if (cloudbaseTools.length === 0) { + server.tool('__noop__', 'Placeholder tool. CloudBase tools are unavailable in degraded mode.', {}, async () => ({ + content: [{ type: 'text' as const, text: 'CloudBase tools unavailable (degraded mode)' }], + isError: true, + })) + } + + // ── publishMiniprogram (沙箱业务镜像 /api/jobs/miniprogram-deploy) ── + server.tool( + 'publishMiniprogram', + '小程序发布/预览工具。支持预览(preview)和上传(upload)两种操作。', + { + action: z.enum(['preview', 'upload']).describe('操作类型:preview=预览, upload=上传'), + projectPath: z.string().describe('小程序项目路径(沙箱内的绝对路径)'), + appId: z.string().describe('微信小程序 AppId'), + version: z.string().optional().describe('版本号'), + description: z.string().optional().describe('版本描述'), + robot: z.number().optional().describe('CI 机器人编号'), + }, + async (args: Record) => { + try { + return await runStatefulPublishMiniprogram(args, (p, init) => sandbox.request(p, init), getMpDeployCredentials) + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e) + return mcpTextPayload(JSON.stringify({ error: true, message }), true) + } + }, + ) + + server.tool( + 'getDeployJobStatus', + '查询小程序发布/预览任务的状态。', + { jobId: z.string().describe('publishMiniprogram 返回的 jobId') }, + async (args: Record) => { + try { + return await runStatefulDeployJobStatus(args.jobId as string, (p, init) => sandbox.request(p, init)) + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e) + return mcpTextPayload(JSON.stringify({ error: true, message }), true) + } + }, + ) + + // ── Wire InMemoryTransport pair ── + const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + const client = new Client({ name: 'stateful-cloudbase-agent', version: '1.0.0' }) + await client.connect(clientTransport) + + // ── Build SDK MCP Server ── + const sdkTools = cloudbaseTools + .filter((t: any) => t.name !== 'logout' && t.name !== 'interactiveDialog') + .map((t: any) => { + const zodShape = jsonSchemaToZodRawShape(t.inputSchema) + return sdkTool( + t.name, + (t.description ?? `CloudBase tool: ${t.name}`) + + '\n\nNOTE: localPath refers to paths inside the container workspace.', + zodShape as any, + async (args: Record) => { + try { + const result = await mcporterCall(t.name, args) + const output = result.output ?? '' + if (t.name === 'uploadFiles' && onArtifact && output) { + try { + const deployUrl = extractDeployUrl(output, isFilePath(String(args.localPath || ''))) + if (deployUrl) { + log(`[stateful-mcp] deploy artifact detected\n`) + onArtifact({ + title: 'Web 应用已部署', + contentType: 'link', + data: deployUrl, + metadata: { deploymentType: 'web' }, + }) + } + } catch { + // ignore + } + } + return { content: [{ type: 'text' as const, text: output }] } + } catch (e: any) { + return { content: [{ type: 'text' as const, text: `Error: ${e.message}` }], isError: true } + } + }, + ) + }) + + // SDK-wrapped publishMiniprogram (mirrors server.tool above) + sdkTools.push( + sdkTool( + 'publishMiniprogram', + '小程序发布/预览工具。支持预览(preview)和上传(upload)两种操作。', + { + action: z.enum(['preview', 'upload']).describe('操作类型'), + projectPath: z.string().describe('小程序项目路径'), + appId: z.string().describe('微信小程序 AppId'), + version: z.string().optional().describe('版本号'), + description: z.string().optional().describe('版本描述'), + robot: z.number().optional().describe('CI 机器人编号'), + }, + async (args: Record) => { + try { + return await runStatefulPublishMiniprogram( + args, + (p, init) => sandbox.request(p, init), + getMpDeployCredentials, + ) + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e) + return mcpTextPayload(JSON.stringify({ error: true, message }), true) + } + }, + ), + ) + + sdkTools.push( + sdkTool( + 'getDeployJobStatus', + '查询小程序发布/预览任务的状态。', + { jobId: z.string().describe('publishMiniprogram 返回的 jobId') }, + async (args: Record) => { + try { + return await runStatefulDeployJobStatus(args.jobId as string, (p, init) => sandbox.request(p, init)) + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e) + return mcpTextPayload(JSON.stringify({ error: true, message }), true) + } + }, + ), + ) + + // ── cronTask (CRUD via OpenVibeCoding 本地 DB; identical to SCF version) ── + if (depsUserId) { + sdkTools.push( + sdkTool( + 'cronTask', + '定时任务管理工具。支持创建、查询、更新、删除定时任务。定时任务到达设定时间后会自动创建 Agent 会话执行指定操作。当用户提到定时、定期、每天/每周/每小时执行时使用此工具。', + { + action: z.enum(['create', 'list', 'update', 'delete']).describe('操作类型'), + id: z.string().optional().describe('任务 ID(update/delete 时必填)'), + name: z.string().optional().describe('任务名称(create 时必填)'), + prompt: z.string().optional().describe('Agent 要执行的内容(create 时必填)'), + cronExpression: z.string().optional().describe('Cron 表达式,如 "0 20 * * *"(create 时必填)'), + enabled: z.boolean().optional().describe('是否启用,默认 true'), + }, + async (args: Record) => { + try { + const action = args.action as string + const userId = depsUserId + + if (action === 'list') { + const tasks = await getDb().cronTasks.findByUserId(userId) + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: true, + data: tasks.map((t) => ({ + id: t.id, + name: t.name, + prompt: t.prompt, + cronExpression: t.cronExpression, + enabled: t.enabled, + lastRunAt: t.lastRunAt, + })), + }), + }, + ], + } + } + + if (action === 'create') { + const name = args.name as string + const prompt = args.prompt as string + const cronExpression = args.cronExpression as string + const enabled = (args.enabled as boolean) ?? true + + if (!name || !prompt || !cronExpression) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: true, message: 'create 需要 name、prompt、cronExpression' }), + }, + ], + isError: true, + } + } + if (!cron.validate(cronExpression)) { + return { + content: [ + { type: 'text' as const, text: JSON.stringify({ error: true, message: 'Cron 表达式无效' }) }, + ], + isError: true, + } + } + + const newTask = await getDb().cronTasks.create({ + id: nanoid(), + userId, + name, + prompt, + cronExpression, + enabled, + repoUrl: null, + selectedAgent: 'codebuddy', + selectedModel: depsCurrentModel || 'hy3-preview-ioa', + lastRunAt: null, + nextRunAt: null, + lockedBy: null, + lockedAt: null, + }) + if (newTask.enabled) scheduleTask(newTask) + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: true, + id: newTask.id, + name: newTask.name, + cronExpression: newTask.cronExpression, + enabled: newTask.enabled, + }), + }, + ], + } + } + + if (action === 'update') { + const id = args.id as string + if (!id) { + return { + content: [ + { type: 'text' as const, text: JSON.stringify({ error: true, message: 'update 需要 id' }) }, + ], + isError: true, + } + } + if (args.cronExpression && !cron.validate(args.cronExpression as string)) { + return { + content: [ + { type: 'text' as const, text: JSON.stringify({ error: true, message: 'Cron 表达式无效' }) }, + ], + isError: true, + } + } + const updateData: Record = {} + if (args.name !== undefined) updateData.name = args.name + if (args.prompt !== undefined) updateData.prompt = args.prompt + if (args.cronExpression !== undefined) updateData.cronExpression = args.cronExpression + if (args.enabled !== undefined) updateData.enabled = args.enabled + + const updated = await getDb().cronTasks.update(id, userId, updateData) + if (!updated) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ error: true, message: '任务不存在' }) }], + isError: true, + } + } + if (updated.enabled) scheduleTask(updated) + else unscheduleTask(updated.id) + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: true, + id: updated.id, + name: updated.name, + enabled: updated.enabled, + }), + }, + ], + } + } + + if (action === 'delete') { + const id = args.id as string + if (!id) { + return { + content: [ + { type: 'text' as const, text: JSON.stringify({ error: true, message: 'delete 需要 id' }) }, + ], + isError: true, + } + } + const existing = await getDb().cronTasks.findByIdAndUserId(id, userId) + if (!existing) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ error: true, message: '任务不存在' }) }], + isError: true, + } + } + unscheduleTask(id) + await getDb().cronTasks.delete(id, userId) + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: true, message: '已删除' }) }], + } + } + + return { + content: [{ type: 'text' as const, text: JSON.stringify({ error: true, message: '未知操作' }) }], + isError: true, + } + } catch (e: any) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ error: true, message: e.message }) }], + isError: true, + } + } + }, + ), + ) + } + + const sdkServer = createSdkMcpServer({ + name: 'cloudbase', + version: '1.0.0', + tools: sdkTools, + }) + + log( + `[stateful-mcp] Ready. baseUrl=${sandbox.baseUrl} sandboxId=${sandbox.id} tools=${cloudbaseTools.length} miniprogram=enabled\n`, + ) + + return { + client, + server, + sdkServer, + close: async () => { + try { + await client.close() + } catch {} + try { + await server.close() + } catch {} + }, + } +} diff --git a/packages/server/src/sandbox/task-sandbox.ts b/packages/server/src/sandbox/task-sandbox.ts new file mode 100644 index 0000000..9b12393 --- /dev/null +++ b/packages/server/src/sandbox/task-sandbox.ts @@ -0,0 +1,181 @@ +/** + * Task-level sandbox helpers (routes/tasks.ts). + * All acquisition goes through StatefulSandboxProvider. + */ + +import { getDb } from '../db/index.js' +import type { Task } from '../db/types.js' +import { buildStatefulAcquireContext } from './acquire-context.js' +import { getSandboxProvider } from './provider/factory.js' +import type { SandboxInstance, SandboxProvider } from './provider/types.js' +import { statefulReadBinaryFile, statefulReadTextFile } from './stateful/e2b-native-client.js' + +export interface CommandResult { + success: boolean + exitCode?: number + output?: string + error?: string +} + +export interface TaskSandboxOptions { + allowCreate?: boolean + /** Reserved for future coding-mode acquire hints */ + isCodingMode?: boolean +} + +export async function getTaskSandbox( + task: Task, + envId: string, + options?: TaskSandboxOptions, +): Promise { + try { + const provider = getSandboxProvider() + const acquireCtx = buildStatefulAcquireContext({ + envId, + taskId: task.id, + userId: task.userId, + sandboxMode: task.sandboxMode, + sandboxId: task.sandboxId, + }) + + const existing = provider.getExisting ? await provider.getExisting(acquireCtx) : null + if (existing) return existing + + // Task already bound to a sandbox (e.g. after page refresh): reconnect like preview-url does. + // Without this, list-dir/files fail with 410 while preview still works (allowCreate there). + const mayAcquire = options?.allowCreate || !!task.sandboxId + if (!mayAcquire) return null + + const acquired = await provider.acquire(acquireCtx) + if (task.sandboxId !== acquired.id) { + await getDb().tasks.update(task.id, { sandboxId: acquired.id, updatedAt: Date.now() }) + } + return acquired + } catch { + return null + } +} + +export async function runCommandInSandbox( + sandbox: SandboxInstance, + command: string, + timeout = 30000, +): Promise { + try { + const response = await sandbox.request('/api/tools/bash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command, timeout }), + }) + const data = (await response.json()) as { + success: boolean + result?: { output: string; exitCode: number } + error?: string + } + if (!data.success) { + return { success: false, error: data.error || 'Command failed' } + } + return { + success: data.result?.exitCode === 0, + exitCode: data.result?.exitCode, + output: data.result?.output || '', + } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Command failed' } + } +} + +export type PackageManager = 'pnpm' | 'yarn' | 'npm' + +export async function detectPackageManager(sandbox: SandboxInstance): Promise { + const pnpmCheck = await runCommandInSandbox(sandbox, 'test -f pnpm-lock.yaml && echo "yes" || echo "no"') + if (pnpmCheck.output?.trim() === 'yes') return 'pnpm' + const yarnCheck = await runCommandInSandbox(sandbox, 'test -f yarn.lock && echo "yes" || echo "no"') + if (yarnCheck.output?.trim() === 'yes') return 'yarn' + return 'npm' +} + +export async function readFileFromSandbox( + sandbox: SandboxInstance, + filePath: string, + options?: { isImage?: boolean }, +): Promise<{ content: string; found: boolean; isBase64?: boolean }> { + try { + const path = (filePath || '').trim() + if (!path) return { content: '', found: false } + + if (sandbox.backend === 'stateful') { + if (options?.isImage) { + const bytes = await statefulReadBinaryFile(sandbox, path) + if (!bytes) return { content: '', found: false } + return { content: Buffer.from(bytes).toString('base64'), found: true, isBase64: true } + } + const text = await statefulReadTextFile(sandbox, path) + if (text === null) return { content: '', found: false } + return { content: text, found: true } + } + + const res = await sandbox.request('/api/tools/read', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, isImage: options?.isImage }), + }) + if (!res.ok) return { content: '', found: false } + const data = (await res.json()) as { success?: boolean; result?: { content?: string } } + const content = data.result?.content ?? '' + return { content, found: !!data.success && content.length > 0 } + } catch { + return { content: '', found: false } + } +} + +/** Download file bytes via 沙箱业务镜像 POST /api/tools/files_download (production 沙箱业务镜像 has no /e2b-compatible). */ +export async function downloadFileFromSandbox(sandbox: SandboxInstance, filePath: string): Promise { + try { + const res = await sandbox.request('/api/tools/files_download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filePath }), + signal: AbortSignal.timeout(120_000), + }) + const data = (await res.json()) as { success?: boolean; result?: { contentBase64?: string } } + if (!data.success || !data.result?.contentBase64) return null + return Uint8Array.from(Buffer.from(data.result.contentBase64, 'base64')) + } catch { + return null + } +} + +export async function writeFileToSandbox( + sandbox: SandboxInstance, + filePath: string, + content: string, +): Promise { + try { + const res = await sandbox.request('/api/tools/write', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filePath, content }), + }) + return res.ok + } catch { + return false + } +} + +export function getProvider(): SandboxProvider { + return getSandboxProvider() +} + +/** + * @deprecated Prefer waitForSandboxViteReady — 沙箱业务镜像 vite-dev-manager owns port 5173. + */ +export async function ensureDevServerStarted( + sandbox: SandboxInstance, + port: number, +): Promise<{ success: boolean; message?: string }> { + const { isViteReadyResult, waitForSandboxViteReady } = await import('./wait-vite-ready.js') + const result = await waitForSandboxViteReady(sandbox, { port }) + if (isViteReadyResult(result)) return { success: true } + return { success: false, message: result.message } +} diff --git a/packages/server/src/sandbox/tool-override.ts b/packages/server/src/sandbox/tool-override.ts index fdc8312..e8c43e0 100644 --- a/packages/server/src/sandbox/tool-override.ts +++ b/packages/server/src/sandbox/tool-override.ts @@ -416,63 +416,10 @@ async function drainPtyOutput(baseUrl: string, headers: Record, } /** - * 调用 Process/List 获取沙箱中所有存活进程。 + * 确保指定 pid 在 ptyTaskRegistry 中存在(仅内存;沙箱业务镜像 无 e2b Process/List)。 */ -async function listSandboxProcesses( - baseUrl: string, - headers: Record, -): Promise> { - try { - const res = await fetch(`${baseUrl}/e2b-compatible/process.Process/List`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...headers }, - body: '{}', - }) - if (!res.ok) return [] - - const data = (await res.json()) as any - return (data?.processes ?? []) - .filter((p: any) => p.pid) - .map((p: any) => ({ - pid: p.pid, - cmd: p.config?.cmd ?? '', - args: p.config?.args ?? [], - })) - } catch { - return [] - } -} - -/** - * 确保指定 pid 在 ptyTaskRegistry 中存在。 - * 内存没有则通过 Process/List 检查沙箱中是否存活,存活则恢复。 - */ -async function ensurePtyTask( - taskId: string, - baseUrl: string, - headers: Record, -): Promise { - const existing = ptyTaskRegistry.get(taskId) - if (existing) return existing - - const pid = Number(taskId) - if (!pid || isNaN(pid)) return null - - const processes = await listSandboxProcesses(baseUrl, headers) - const found = processes.find((p) => p.pid === pid) - if (!found) return null - - const restored: PtyTask = { - pid, - title: `(restored) ${found.cmd} ${found.args.join(' ')}`.trim(), - startTime: Date.now(), - status: 'running', - nextSeq: 0, - stdout: '', - exited: false, - } - ptyTaskRegistry.set(taskId, restored) - return restored +async function ensurePtyTask(taskId: string): Promise { + return ptyTaskRegistry.get(taskId) ?? null } /** @@ -710,7 +657,7 @@ export function overrideTools(toolMap: Map): void { * 返回 null 表示该 taskId 在沙箱中也不存在(应回退原始实现)。 */ async function formatPtyTaskOutput(taskId: string, filter?: string): Promise<{ content: string } | null> { - const task = await ensurePtyTask(taskId, baseUrl, headers) + const task = await ensurePtyTask(taskId) if (!task) return null // lazy:只在此刻拉取所有积压输出 @@ -774,7 +721,7 @@ export function overrideTools(toolMap: Map): void { taskOutputTool.execute = async (params: any, context: ToolContext, extra?: any) => { const taskId = String(params.task_id || params.shell_id || '') - const task = await ensurePtyTask(taskId, baseUrl, headers) + const task = await ensurePtyTask(taskId) if (task) { const shouldBlock = params.block !== false @@ -803,7 +750,7 @@ export function overrideTools(toolMap: Map): void { taskStopTool.execute = async (params: any, context: ToolContext, extra?: any) => { const taskId = String(params.task_id || params.shell_id || '') - const task = await ensurePtyTask(taskId, baseUrl, headers) + const task = await ensurePtyTask(taskId) if (!task) return originalTaskStopExecute.call(taskStopTool, params, context, extra) return killPtyTask(baseUrl, headers, task, taskId) @@ -875,20 +822,17 @@ export function overrideTools(toolMap: Map): void { return cdnUrl } - // ── fallback:上传到沙箱 /e2b-compatible/files ── + // ── fallback:上传到沙箱 via 沙箱业务镜像 /api/tools/files_upload ── const sandboxRelPath = `generated-images/${filename}` - const form = new FormData() - form.append('file', blob, filename) - - const uploadRes = await fetch(`${baseUrl}/e2b-compatible/files?path=${encodeURIComponent(sandboxRelPath)}`, { + const contentBase64 = Buffer.from(await blob.arrayBuffer()).toString('base64') + const uploadRes = await fetch(`${baseUrl}/api/tools/files_upload`, { method: 'POST', - headers: { ...headers }, - body: form, + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify({ path: sandboxRelPath, contentBase64 }), }) - - if (!uploadRes.ok) { - const text = await uploadRes.text().catch(() => '') - throw new Error(`ImageGen upload to sandbox failed: ${uploadRes.status} ${text}`) + const uploadData = (await uploadRes.json().catch(() => ({}))) as { success?: boolean; error?: string } + if (!uploadRes.ok || !uploadData.success) { + throw new Error(`ImageGen upload to sandbox failed: ${uploadData.error || uploadRes.status}`) } return sandboxRelPath @@ -936,7 +880,7 @@ export function overrideTools(toolMap: Map): void { killShellTool.execute = async (params: any, context: ToolContext, extra?: any) => { const shellId = String(params.shell_id || '') - const task = await ensurePtyTask(shellId, baseUrl, headers) + const task = await ensurePtyTask(shellId) if (!task) return originalKillExecute.call(killShellTool, params, context, extra) return killPtyTask(baseUrl, headers, task, shellId) diff --git a/packages/server/src/sandbox/trw-deploy-adapter.ts b/packages/server/src/sandbox/trw-deploy-adapter.ts new file mode 100644 index 0000000..c4b8fb0 --- /dev/null +++ b/packages/server/src/sandbox/trw-deploy-adapter.ts @@ -0,0 +1,127 @@ +/** + * 沙箱业务镜像 miniprogram deploy job adapter. + * + * As of the 沙箱业务镜像 route refactor (post commit f930f87), the miniprogram deploy + * surface lives at: + * + * POST /api/jobs/miniprogram-deploy → start a job + * GET /api/jobs/:jobId → poll a job (any kind) + * + * Sync responses return a Job record directly; long-running responses + * return HTTP 202 with `{ jobId, status: 'running' }`. Both differ from the + * older `{ success, async, ... }` envelope this codebase was originally + * written against. This helper translates the new shape back into the + * legacy envelope so the MCP tool layer can stay simple and stable. + */ + +/** 沙箱业务镜像 Job record returned by /api/jobs/* endpoints. */ +export interface TrwJob { + jobId: string + kind: 'miniprogram-deploy' | string + /** Kind-specific action label (e.g. 'preview' | 'upload'). */ + action: string + status: 'running' | 'completed' | 'failed' + startedAt: number + completedAt?: number + /** Successful payload (e.g. miniprogram-ci subPackageInfo + qrcode). */ + result?: unknown + /** Failure message. */ + error?: string + /** Tool-specific error code (miniprogram-ci.errCode). */ + errCode?: unknown + /** Tool-specific error message (miniprogram-ci.errMsg). */ + errMsg?: unknown + logs: string[] +} + +/** Legacy envelope the MCP tool layer consumes. */ +export interface LegacyDeployEnvelope { + /** When set, the tool must return immediately and tell the user to poll. */ + async?: true + jobId?: string + /** Final status of a synchronous response. Absent when async=true. */ + success?: boolean + /** Success payload, mirrors TrwJob.result. */ + result?: unknown + /** Failure message. */ + error?: string +} + +/** + * Adapt the body of `POST /api/jobs/miniprogram-deploy` (HTTP 200 or 202) + * into the legacy `{ async / success / result / error }` envelope. + * + * - 202 with `{ status: "running", jobId }` → `{ async: true, jobId }` + * - 200 with TrwJob, status=completed → `{ success: true, result }` + * - 200 with TrwJob, status=failed → `{ success: false, error, result: { errCode, errMsg, logs } }` + * + * If the response body is missing or in an unexpected shape, returns a + * failure envelope so the caller surfaces something useful to the user. + */ +export function adaptMiniprogramDeployStart(httpStatus: number, body: unknown): LegacyDeployEnvelope { + if (!body || typeof body !== 'object') { + return { success: false, error: `Empty or invalid response (HTTP ${httpStatus})` } + } + + const obj = body as Record + + // 202 — running job, hand back jobId for polling. + if (httpStatus === 202 || obj.status === 'running') { + if (typeof obj.jobId === 'string') { + return { async: true, jobId: obj.jobId } + } + return { + success: false, + error: 'Async response missing jobId', + } + } + + // 200 — finished job. + const job = obj as Partial + if (job.status === 'completed') { + return { success: true, result: job.result } + } + if (job.status === 'failed') { + return { + success: false, + error: job.error || 'Deploy failed', + result: { + errCode: job.errCode, + errMsg: job.errMsg, + logs: job.logs, + }, + } + } + + // Unknown shape — pass body through under success:false so caller can see it. + return { + success: false, + error: `Unexpected response shape (HTTP ${httpStatus})`, + result: body, + } +} + +/** + * Adapt the body of `GET /api/jobs/:jobId` into a flat status payload the + * MCP tool layer can stringify and return to the user. + * + * The MCP tool used to return raw body content unchanged, so we keep the + * existing shape (just bubbled up untouched) — but we strip the verbose + * `kind` discriminator that isn't useful at the caller end. + */ +export function adaptDeployJobStatus(body: unknown): unknown { + if (!body || typeof body !== 'object') return body + const job = body as Partial + return { + jobId: job.jobId, + action: job.action, + status: job.status, + startedAt: job.startedAt, + completedAt: job.completedAt, + result: job.result, + error: job.error, + errCode: job.errCode, + errMsg: job.errMsg, + logs: job.logs, + } +} diff --git a/packages/server/src/sandbox/trw-miniprogram-client.ts b/packages/server/src/sandbox/trw-miniprogram-client.ts new file mode 100644 index 0000000..a8ae77f --- /dev/null +++ b/packages/server/src/sandbox/trw-miniprogram-client.ts @@ -0,0 +1,121 @@ +/** + * 沙箱业务镜像 vibecoding miniprogram deploy HTTP client (shared by Stateful MCP + OpenCode middleware). + * + * 沙箱业务镜像 routes (ENABLE_VIBECODING): + * POST /api/jobs/miniprogram-deploy + * GET /api/jobs/:jobId + * + * Legacy paths removed: /api/miniprogram/deploy, /api/miniprogram/deploy/status + */ + +import { adaptDeployJobStatus, adaptMiniprogramDeployStart, type LegacyDeployEnvelope } from './trw-deploy-adapter.js' + +export type TrwHttp = (path: string, init?: RequestInit) => Promise + +export interface MiniprogramDeployRequest { + appid: string + privateKey: string + action: 'preview' | 'upload' + projectPath: string + version?: string + description?: string + robot?: number +} + +export type MiniprogramDeployOutcome = + | { ok: true; envelope: LegacyDeployEnvelope; httpStatus: number } + | { ok: false; message: string; httpStatus: number; result?: unknown } + +function trwErrorMessage(raw: unknown, httpStatus: number): string { + if (raw && typeof raw === 'object') { + const r = raw as Record + const detail = r.detail ?? r.error ?? r.message ?? r.title + if (typeof detail === 'string' && detail.length > 0) return detail + } + return `HTTP ${httpStatus}` +} + +/** Start miniprogram deploy; adapts 沙箱业务镜像 Job / 202 body to legacy MCP envelope. */ +export async function startTrwMiniprogramDeploy( + http: TrwHttp, + params: MiniprogramDeployRequest, +): Promise { + const res = await http('/api/jobs/miniprogram-deploy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + appid: params.appid, + privateKey: params.privateKey, + action: params.action, + projectPath: params.projectPath, + version: params.version, + description: params.description, + robot: params.robot, + }), + signal: AbortSignal.timeout(120_000), + }) + + const raw = (await res.json().catch(() => null)) as unknown + if (!res.ok && res.status !== 202) { + return { + ok: false, + httpStatus: res.status, + message: trwErrorMessage(raw, res.status), + result: raw, + } + } + + return { + ok: true, + httpStatus: res.status, + envelope: adaptMiniprogramDeployStart(res.status, raw), + } +} + +/** Poll deploy job status (GET /api/jobs/:jobId). */ +export async function pollTrwMiniprogramJob( + http: TrwHttp, + jobId: string, +): Promise<{ ok: boolean; httpStatus: number; body: unknown }> { + const res = await http(`/api/jobs/${encodeURIComponent(jobId)}`, { + signal: AbortSignal.timeout(30_000), + }) + const raw = (await res.json().catch(() => null)) as unknown + if (!res.ok) { + return { + ok: false, + httpStatus: res.status, + body: raw ?? { error: true, message: trwErrorMessage(raw, res.status) }, + } + } + return { ok: true, httpStatus: res.status, body: adaptDeployJobStatus(raw) } +} + +/** JSON string for MCP middleware tools from a deploy start outcome. */ +export function miniprogramStartToJson(outcome: MiniprogramDeployOutcome): string { + if (!outcome.ok) { + return JSON.stringify({ + error: true, + status: outcome.httpStatus, + message: outcome.message, + result: outcome.result, + }) + } + + const body = outcome.envelope + if (body.async) { + return JSON.stringify({ + async: true, + jobId: body.jobId, + message: '部署仍在进行中,请稍后使用 getDeployJobStatus 工具查询结果', + }) + } + if (!body.success) { + return JSON.stringify({ + error: true, + message: body.error || (body.result as { errMsg?: string } | undefined)?.errMsg || 'Deploy failed', + result: body.result, + }) + } + return JSON.stringify(body) +} diff --git a/packages/server/src/sandbox/ttyd-gateway-port.ts b/packages/server/src/sandbox/ttyd-gateway-port.ts new file mode 100644 index 0000000..cf46c78 --- /dev/null +++ b/packages/server/src/sandbox/ttyd-gateway-port.ts @@ -0,0 +1,88 @@ +/** + * Map public ttyd virtual port (7681) to the gateway preview path that actually reaches TRW. + * + * TCB gateway returns 404 for GET /preview/7681/ even with E2b-Sandbox-Port 9000, while + * /preview/{ttydDynamicPort}/ works. Browser URLs stay on 7681; only upstream fetch uses the dynamic port. + */ + +import type { SandboxInstance } from './provider/types.js' +import { TTYD_VIRTUAL_PORT } from './ttyd-preview.js' + +export const TTYD_BACKEND_MIN = 29100 +export const TTYD_BACKEND_MAX = 29199 + +type PreviewPortRow = { + port?: number + service?: string + virtual?: boolean + targetPort?: number +} + +function isBackendPort(port: number): boolean { + return port >= TTYD_BACKEND_MIN && port <= TTYD_BACKEND_MAX +} + +/** Parse `ttyd -W -p 29100` from a pgrep cmdline (used when /preview/ports drops targetPort). */ +export function parseTtydBackendPortFromCmdline(cmdline: string): number | null { + const m = cmdline.match(/\bttyd\b[^]*?\s-p\s+(\d+)/) + if (!m) return null + const port = Number.parseInt(m[1]!, 10) + if (!Number.isFinite(port) || !isBackendPort(port)) return null + return port +} + +async function listPreviewPorts(sandbox: SandboxInstance): Promise { + try { + const res = await sandbox.request('/preview/ports', { signal: AbortSignal.timeout(10_000) }) + if (!res.ok) return [] + const info = (await res.json()) as { ports?: PreviewPortRow[] } + return Array.isArray(info.ports) ? info.ports : [] + } catch { + return [] + } +} + +async function discoverTtydBackendPort(sandbox: SandboxInstance): Promise { + try { + const res = await sandbox.request('/api/tools/bash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + command: "pgrep -fa 'ttyd -W -p' 2>/dev/null | head -1 || true", + timeout: 15_000, + }), + signal: AbortSignal.timeout(20_000), + }) + if (!res.ok) return null + const json = (await res.json()) as { result?: { output?: string } } + const line = + json.result?.output + ?.split('\n') + .map((l) => l.trim()) + .find((l) => /\bttyd\b/.test(l)) ?? '' + return parseTtydBackendPortFromCmdline(line) + } catch { + return null + } +} + +/** + * Port segment used in gateway paths `/preview/{port}/...` (not shown in the browser URL). + */ +export async function resolveGatewayPreviewPort(sandbox: SandboxInstance, publicPort: number): Promise { + if (publicPort !== TTYD_VIRTUAL_PORT) return publicPort + + const rows = await listPreviewPorts(sandbox) + const virtual = rows.find((p) => p.port === TTYD_VIRTUAL_PORT && p.service === 'ttyd') + if (virtual?.targetPort && isBackendPort(virtual.targetPort)) return virtual.targetPort + + const backend = rows.find( + (p) => p.service === 'ttyd' && !p.virtual && typeof p.port === 'number' && isBackendPort(p.port), + ) + if (backend?.port) return backend.port + + const discovered = await discoverTtydBackendPort(sandbox) + if (discovered !== null) return discovered + + return publicPort +} diff --git a/packages/server/src/sandbox/ttyd-preview.ts b/packages/server/src/sandbox/ttyd-preview.ts new file mode 100644 index 0000000..3df8649 --- /dev/null +++ b/packages/server/src/sandbox/ttyd-preview.ts @@ -0,0 +1,68 @@ +/** + * Web Terminal preview — public virtual port 7681, proxied as /api/tasks/:id/preview/7681/. + */ + +import type { SandboxInstance } from './provider/types.js' +import { resolveGatewayPreviewPort } from './ttyd-gateway-port.js' + +export const TTYD_VIRTUAL_PORT = 7681 + +export type TtydPreviewProbe = 'ready' | 'starting' | 'unavailable' + +export type TtydPreviewResolve = { + status: TtydPreviewProbe + port: typeof TTYD_VIRTUAL_PORT + retryable: boolean +} + +export async function probeTtydPreviewPort(sandbox: SandboxInstance, gatewayPort: number): Promise { + try { + const res = await sandbox.request(`/preview/${gatewayPort}/`, { + method: 'GET', + redirect: 'follow', + signal: AbortSignal.timeout(25_000), + }) + if (res.status === 503) return 'starting' + if (res.ok) return 'ready' + return 'unavailable' + } catch { + return 'unavailable' + } +} + +async function wakeTtydViaTrw(sandbox: SandboxInstance): Promise { + await sandbox.request('/api/tools/bash', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + command: `curl -s -o /dev/null -w "%{http_code}" --max-time 25 "http://127.0.0.1:9000/preview/${TTYD_VIRTUAL_PORT}/" || true`, + timeout: 30_000, + }), + signal: AbortSignal.timeout(35_000), + }) +} + +export async function resolveTtydPreviewPort(sandbox: SandboxInstance): Promise { + const gatewayPort = await resolveGatewayPreviewPort(sandbox, TTYD_VIRTUAL_PORT) + + let probe = await probeTtydPreviewPort(sandbox, gatewayPort) + if (probe === 'ready') { + return { status: 'ready', port: TTYD_VIRTUAL_PORT, retryable: false } + } + if (probe === 'starting') { + return { status: 'starting', port: TTYD_VIRTUAL_PORT, retryable: true } + } + + await wakeTtydViaTrw(sandbox) + + const gatewayPortAfterWake = await resolveGatewayPreviewPort(sandbox, TTYD_VIRTUAL_PORT) + probe = await probeTtydPreviewPort(sandbox, gatewayPortAfterWake) + if (probe === 'ready') { + return { status: 'ready', port: TTYD_VIRTUAL_PORT, retryable: false } + } + if (probe === 'starting') { + return { status: 'starting', port: TTYD_VIRTUAL_PORT, retryable: true } + } + + return { status: 'unavailable', port: TTYD_VIRTUAL_PORT, retryable: true } +} diff --git a/packages/server/src/sandbox/wait-vite-ready.ts b/packages/server/src/sandbox/wait-vite-ready.ts new file mode 100644 index 0000000..f940f58 --- /dev/null +++ b/packages/server/src/sandbox/wait-vite-ready.ts @@ -0,0 +1,86 @@ +/** + * Wait for 沙箱业务镜像 vite-dev-manager (vibecoding) — do not spawn a second dev server. + * 沙箱业务镜像 master has no /api/vibecoding/status; use /preview/ports + /preview/{port}/ probe only. + */ + +import type { SandboxInstance } from './provider/types.js' + +const DEFAULT_VITE_PORT = 5173 + +export interface ViteReadyProgress { + message: string +} + +export interface ViteReadyResult { + port: number + message?: string +} + +async function probePreviewPort(sandbox: SandboxInstance, port: number): Promise { + try { + // Hits 沙箱业务镜像 preview proxy → maybeEnsureViteDevForPreview (lazy unpack + vite start). + const res = await sandbox.request(`/preview/${port}/`, { + method: 'GET', + signal: AbortSignal.timeout(30_000), + }) + return res.status >= 200 && res.status < 400 + } catch { + return false + } +} + +async function listPreviewPorts(sandbox: SandboxInstance): Promise { + try { + const res = await sandbox.request('/preview/ports', { + signal: AbortSignal.timeout(10_000), + }) + if (!res.ok) return [] + const info = (await res.json()) as { ports?: Array<{ port: number }> } + return Array.isArray(info.ports) ? info.ports.map((p) => p.port) : [] + } catch { + return [] + } +} + +function progressMessage(ports: number[], targetPort: number, probed: boolean): string { + if (probed) return '开发服务器已就绪' + if (ports.includes(targetPort)) return '正在等待开发服务器响应...' + return '正在等待开发服务器...' +} + +/** + * Poll 沙箱业务镜像 until vite preview is reachable. Relies on ensureViteDev() started at workspace init. + */ +export async function waitForSandboxViteReady( + sandbox: SandboxInstance, + options: { + port?: number + maxWaitMs?: number + pollIntervalMs?: number + onProgress?: (p: ViteReadyProgress) => void | Promise + } = {}, +): Promise { + const port = options.port ?? DEFAULT_VITE_PORT + const maxWaitMs = options.maxWaitMs ?? 120_000 + const pollIntervalMs = options.pollIntervalMs ?? 2_000 + const start = Date.now() + + while (Date.now() - start < maxWaitMs) { + const ports = await listPreviewPorts(sandbox) + const probed = await probePreviewPort(sandbox, port) + await options.onProgress?.({ message: progressMessage(ports, port, probed) }) + + if (probed) { + return { port } + } + + await new Promise((r) => setTimeout(r, pollIntervalMs)) + } + + return { port, message: `开发服务器未在 ${maxWaitMs / 1000}s 内就绪` } +} + +/** True when wait result indicates vite is listening. */ +export function isViteReadyResult(result: ViteReadyResult): boolean { + return !result.message +} diff --git a/packages/server/src/sandbox/ws-auth.ts b/packages/server/src/sandbox/ws-auth.ts new file mode 100644 index 0000000..2e9afee --- /dev/null +++ b/packages/server/src/sandbox/ws-auth.ts @@ -0,0 +1,63 @@ +/** + * Shared WebSocket auth: resolve task + sandbox from session cookie. + */ + +import type { IncomingMessage } from 'node:http' +import { getDb } from '../db/index.js' +import { decryptJWE } from '../lib/session.js' +import type { AppSession } from '../middleware/auth.js' +import { getProvisionMode } from '../lib/provision-config.js' +import { getTaskSandbox } from './task-sandbox.js' +import type { SandboxInstance } from './provider/types.js' + +export const SESSION_COOKIE_NAME = 'nex_session' + +export function parseCookie(header: string | undefined, name: string): string | undefined { + if (!header) return undefined + for (const part of header.split(';')) { + const [k, ...rest] = part.trim().split('=') + if (k === name) return rest.join('=') + } + return undefined +} + +async function resolveUserEnvId(userId: string, taskId: string): Promise { + const mode = await getProvisionMode() + if (mode === 'shared') { + return process.env.TCB_ENV_ID ?? null + } + try { + const taskResource = await getDb().userResources.findByTaskId(taskId) + if (taskResource?.userId === userId && taskResource.status === 'success' && taskResource.envId) { + return taskResource.envId + } + } catch { + /* fallback */ + } + const userResource = await getDb().userResources.findByUserId(userId) + if (userResource?.status === 'success' && userResource.envId) return userResource.envId + return null +} + +export async function resolveSandboxForTaskWs(req: IncomingMessage, taskId: string): Promise { + const rawCookie = parseCookie(req.headers.cookie, SESSION_COOKIE_NAME) + if (!rawCookie) return null + + let session: AppSession | undefined + try { + session = await decryptJWE(rawCookie) + } catch { + return null + } + if (!session?.user?.id) return null + + const task = await getDb().tasks.findById(taskId) + if (!task || task.userId !== session.user.id) return null + if (!task.sandboxId) return null + + const envId = await resolveUserEnvId(session.user.id, taskId) + if (!envId) return null + + const taskMode = (task as { mode?: string | null }).mode + return getTaskSandbox(task, envId, { isCodingMode: taskMode === 'coding' }) +} diff --git a/packages/server/src/util/skill-loader-override.ts b/packages/server/src/util/skill-loader-override.ts index 5594c15..b1a8c9b 100644 --- a/packages/server/src/util/skill-loader-override.ts +++ b/packages/server/src/util/skill-loader-override.ts @@ -200,30 +200,23 @@ async function sandboxReadFile(sandbox: SandboxConfig, filePath: string): Promis } } -/** - * List a directory via /e2b-compatible/filesystem.Filesystem/ListDir. - * Returns entries with their type via entry.type field. - * Returns null if the path doesn't exist or is not a directory. - */ -async function sandboxReadDir( - sandbox: SandboxConfig, - dirPath: string, -): Promise | null> { +/** Find SKILL.md paths under a directory via 沙箱业务镜像 /api/tools/glob. */ +async function sandboxFindSkillMdPaths(sandbox: SandboxConfig, dirPath: string): Promise { try { - const res = await fetch(`${sandbox.url}/e2b-compatible/filesystem.Filesystem/ListDir`, { + const res = await fetch(`${sandbox.url}/api/tools/glob`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...sandbox.headers }, - body: JSON.stringify({ path: dirPath, depth: 1 }), + body: JSON.stringify({ pattern: '**/SKILL.md', path: dirPath }), }) - if (!res.ok) return null - const data = (await res.json()) as { entries?: Array<{ name: string; type: string }> } - if (!data.entries) return null - return data.entries.map((e) => ({ - name: e.name, - isDirectory: e.type === 'FILE_TYPE_DIRECTORY', - })) + if (!res.ok) return [] + const data = (await res.json()) as { success?: boolean; result?: { output?: string } } + if (!data.success || !data.result?.output || data.result.output === 'No files found') return [] + return data.result.output + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('(')) } catch { - return null + return [] } } @@ -232,20 +225,8 @@ async function scanSandboxSkillsDirectory( dir: string, source: 'project' | 'user', ): Promise { - // 1 request: list the directory with type info - const entries = await sandboxReadDir(sandbox, dir) - if (!entries) return [] - - // Collect all SKILL.md paths to read (from subdirs and bare files) - const skillFilePaths: string[] = [] - for (const entry of entries) { - const fullPath = `${dir}/${entry.name}` - if (entry.isDirectory) { - skillFilePaths.push(`${fullPath}/SKILL.md`) - } else if (entry.name === 'SKILL.md') { - skillFilePaths.push(fullPath) - } - } + const skillFilePaths = await sandboxFindSkillMdPaths(sandbox, dir) + if (skillFilePaths.length === 0) return [] // Fetch all SKILL.md files in batches of 30, concurrent within each batch const SKILL_READ_BATCH = 30 diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d222122..53ecf97 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,3 +4,5 @@ export * from './types/task' export * from './types/user' export * from './types/acp' export * from './types/config' +export * from './sandbox-log-messages' +export * from './task-log-messages' diff --git a/packages/shared/src/sandbox-log-messages.ts b/packages/shared/src/sandbox-log-messages.ts new file mode 100644 index 0000000..2d8214e --- /dev/null +++ b/packages/shared/src/sandbox-log-messages.ts @@ -0,0 +1,41 @@ +/** + * Static platform log lines for sandbox progress (Logs pane / task.logs). + * User-visible — no dynamic interpolation. + */ + +const SANDBOX_LOG_MESSAGES: Record = { + 'sandbox:template_resolve': '加载沙箱模板', + 'sandbox:template_bind': '绑定沙箱模板', + 'sandbox:template_create': '首次创建沙箱模板', + 'sandbox:template_warmup': '沙箱模板预热中', + 'sandbox:template_update': '同步沙箱模板镜像', + 'sandbox:instance_reuse_session': '复用本会话沙箱连接', + 'sandbox:instance_reuse_shared': '复用环境共享沙箱', + 'sandbox:instance_reuse_task': '复用本任务沙箱', + 'sandbox:instance_resume': '恢复沙箱实例', + 'sandbox:instance_start': '启动沙箱实例', + 'sandbox:pull_image': '拉取沙箱镜像', + 'sandbox:wait_ready': '等待沙箱健康检查', + 'sandbox:init_mcp': '初始化工作区', + 'sandbox:wait_creating': '沙箱启动中', + 'sandbox:create': '创建沙箱', + 'sandbox:reuse': '连接已有沙箱', +} + +export function isSandboxToolName(toolName?: string): boolean { + return typeof toolName === 'string' && toolName.startsWith('sandbox:') +} + +/** Map `sandbox:*` tool name to a static log line, or null to skip. */ +export function sandboxLogMessageForTool(toolName: string, ctx?: { previousPrepareTool?: string }): string | null { + if (toolName === 'sandbox:ready') { + if (ctx?.previousPrepareTool === 'sandbox:init_mcp') { + return '沙箱与工作区已就绪' + } + return '沙箱实例已就绪' + } + if (toolName === 'sandbox:error') { + return '沙箱未就绪(受限模式)' + } + return SANDBOX_LOG_MESSAGES[toolName] ?? null +} diff --git a/packages/shared/src/task-log-messages.ts b/packages/shared/src/task-log-messages.ts new file mode 100644 index 0000000..207b66a --- /dev/null +++ b/packages/shared/src/task-log-messages.ts @@ -0,0 +1,47 @@ +/** + * Static user-visible task log lines (Logs pane). No dynamic interpolation. + */ + +export const LOG_PREFIX_WORKSPACE = '[工作区]' +export const LOG_PREFIX_SERVER = '[SERVER]' + +function workspaceLine(text: string): string { + return `${LOG_PREFIX_WORKSPACE} ${text}` +} + +export const TASK_LOG = { + WORKSPACE_FILE_SAVED: workspaceLine('工作区文件已保存'), + WORKSPACE_FILE_SAVE_FAILED: workspaceLine('工作区文件保存失败'), + WORKSPACE_FILE_CREATED: workspaceLine('工作区已创建新文件'), + WORKSPACE_FILE_CREATE_FAILED: workspaceLine('工作区创建文件失败'), + WORKSPACE_FOLDER_CREATED: workspaceLine('工作区已创建文件夹'), + WORKSPACE_FOLDER_CREATE_FAILED: workspaceLine('工作区创建文件夹失败'), + WORKSPACE_FILE_DELETED: workspaceLine('工作区文件已删除'), + WORKSPACE_FILE_DELETE_FAILED: workspaceLine('工作区文件删除失败'), + WORKSPACE_NO_SANDBOX: workspaceLine('沙箱不可用,无法写入工作区'), + + PLATFORM_SHARED_SANDBOX_STOPPED: '共享沙箱已停止', + PLATFORM_PREVIEW_READY: '开发预览已就绪', + PLATFORM_PREVIEW_RESTARTING: '开发预览已停止,正在重启', + PLATFORM_TERMINAL_READY: 'Web 终端已就绪', + PLATFORM_TERMINAL_UNAVAILABLE: 'Web 终端暂不可用', + PLATFORM_ARCHIVE_PUSH_OK: '变更已提交归档', + PLATFORM_ARCHIVE_PUSH_FAILED: '归档推送失败', + PLATFORM_DEPLOYMENT_RECORDED: '部署记录已保存', + PLATFORM_TASK_STOPPED: '任务已由用户停止', + PLATFORM_SANDBOX_STARTED: '沙箱已启动成功', +} as const + +const ALLOWED_TASK_LOG_MESSAGES = new Set(Object.values(TASK_LOG)) + +export function isAllowedTaskLogMessage(message: string): boolean { + return ALLOWED_TASK_LOG_MESSAGES.has(message) +} + +export function isWorkspaceTaskLogMessage(message: string): boolean { + return message.startsWith(LOG_PREFIX_WORKSPACE) +} + +export function isServerTaskLogMessage(message: string): boolean { + return message.startsWith(LOG_PREFIX_SERVER) +} diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 4e316c3..a73753f 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -470,7 +470,10 @@ export interface AgentCallbackMessage { | 'ask_user' | 'artifact' | 'agent_phase' + | 'log' content?: string + /** log: severity (static message in content) */ + logLevel?: 'info' | 'error' | 'success' | 'command' name?: string input?: unknown /** tool_call id 或 assistant message id (取决于消息类型) */ diff --git a/packages/web/src/components/app-layout.tsx b/packages/web/src/components/app-layout.tsx index 0153a55..3a9516d 100644 --- a/packages/web/src/components/app-layout.tsx +++ b/packages/web/src/components/app-layout.tsx @@ -1,12 +1,9 @@ -import { useState, useEffect, createContext, useContext, useCallback } from 'react' +import { useState, useEffect, createContext, useContext, useCallback, useRef } from 'react' import { TaskSidebar } from '@/components/task-sidebar' import type { Task } from '@coder/shared' -import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' -import { Plus, Trash2 } from 'lucide-react' -import { Link } from 'react-router' -import { getSidebarWidth, setSidebarWidth, getSidebarOpen, setSidebarOpen } from '@/lib/utils/cookies' +import { loadTaskList, prependTask } from '@/lib/task-list-store' import { ConnectorsProvider } from '@/components/connectors-provider' +import { getSidebarWidth, setSidebarWidth, getSidebarOpen, setSidebarOpen } from '@/lib/utils/cookies' interface AppLayoutProps { children: React.ReactNode @@ -44,60 +41,7 @@ function generateId(): string { return Math.random().toString(36).substring(2) + Date.now().toString(36) } -function SidebarLoader({ width }: { width: number }) { - return ( -
-
-
- {/* Tabs */} -
- - {/* */} -
-
- - - - -
-
-
- -
- {/* Loading skeleton for tasks */} - {Array.from({ length: 3 }).map((_, i) => ( - - {/* Empty skeleton - just the card shape */} - - ))} -
-
- ) -} - export function AppLayout({ children, initialSidebarWidth, initialSidebarOpen, initialIsMobile }: AppLayoutProps) { - const [tasks, setTasks] = useState([]) - const [isLoading, setIsLoading] = useState(true) - // Initialize sidebar state based on user agent and preferences - // On mobile (from user agent): always closed - // On desktop: use saved preference or default to open const [isSidebarOpen, setIsSidebarOpen] = useState(() => { if (initialIsMobile) return false return initialSidebarOpen ?? true @@ -107,55 +51,41 @@ export function AppLayout({ children, initialSidebarWidth, initialSidebarOpen, i const [isDesktop, setIsDesktop] = useState(!initialIsMobile) const [hasMounted, setHasMounted] = useState(false) - // Update sidebar width and save to cookie const updateSidebarWidth = (newWidth: number) => { setSidebarWidthState(newWidth) setSidebarWidth(newWidth) } - // Update sidebar open state and save to cookie (desktop only) const updateSidebarOpen = useCallback((isOpen: boolean, saveToCookie = true) => { setIsSidebarOpen(isOpen) - // Only save to cookie on desktop screens if (saveToCookie && typeof window !== 'undefined' && window.innerWidth >= 1024) { setSidebarOpen(isOpen) } }, []) - // Verify screen size after mount and update if needed useEffect(() => { const actualIsDesktop = window.innerWidth >= 1024 - - // Only update if there's a mismatch between user agent detection and actual screen size if (actualIsDesktop !== isDesktop) { setIsDesktop(actualIsDesktop) - if (!actualIsDesktop) { - // Screen is actually mobile but user agent said desktop setIsSidebarOpen(false) } else if (actualIsDesktop && initialIsMobile) { - // Screen is actually desktop but user agent said mobile - // Use saved preference or default to open const savedPreference = getSidebarOpen() setIsSidebarOpen(savedPreference ?? initialSidebarOpen ?? true) } } - - // Mark as mounted to enable transitions setHasMounted(true) }, [isDesktop, initialIsMobile, initialSidebarOpen]) - // Fetch tasks on component mount - useEffect(() => { - fetchTasks() + const refreshTasks = useCallback(async () => { + await loadTaskList() }, []) - // Poll for task updates every 15 seconds useEffect(() => { + void loadTaskList() const interval = setInterval(() => { - fetchTasks() + if (document.visibilityState === 'visible') void loadTaskList() }, 15000) - return () => clearInterval(interval) }, []) @@ -163,18 +93,12 @@ export function AppLayout({ children, initialSidebarWidth, initialSidebarOpen, i updateSidebarOpen(!isSidebarOpen) }, [isSidebarOpen, updateSidebarOpen]) - // Handle window resize - close sidebar on mobile and update isDesktop useEffect(() => { const handleResize = () => { const newIsDesktop = window.innerWidth >= 1024 setIsDesktop(newIsDesktop) - - // On mobile, always close sidebar - if (!newIsDesktop && isSidebarOpen) { - setIsSidebarOpen(false) - } + if (!newIsDesktop && isSidebarOpen) setIsSidebarOpen(false) } - window.addEventListener('resize', handleResize) return () => window.removeEventListener('resize', handleResize) }, [isSidebarOpen]) @@ -186,82 +110,64 @@ export function AppLayout({ children, initialSidebarWidth, initialSidebarOpen, i toggleSidebar() } } - document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [toggleSidebar]) - const fetchTasks = async () => { - try { - const response = await fetch('/api/tasks') - if (response.ok) { - const data = await response.json() - setTasks(data.tasks) - } else if (response.status === 401) { - // User is not authenticated, show empty tasks - setTasks([]) + const addTaskOptimistically = useCallback( + (taskData: { + prompt: string + repoUrl: string + selectedAgent: string + selectedModel: string + installDependencies: boolean + maxDuration: number + }) => { + const id = generateId() + const optimisticTask: Task = { + id, + userId: 'temp', + prompt: taskData.prompt, + title: null, + repoUrl: taskData.repoUrl, + envId: null, + selectedAgent: taskData.selectedAgent, + selectedModel: taskData.selectedModel, + installDependencies: taskData.installDependencies, + maxDuration: taskData.maxDuration, + keepAlive: false, + enableBrowser: false, + mode: 'default', + status: 'pending', + progress: 0, + logs: [], + error: null, + branchName: null, + sandboxId: null, + sandboxSessionId: null, + sandboxCwd: null, + sandboxMode: null, + agentSessionId: null, + sandboxUrl: null, + previewUrl: null, + mcpServerIds: null, + prUrl: null, + prNumber: null, + prStatus: null, + prMergeCommitSha: null, + createdAt: Date.now(), + updatedAt: Date.now(), + completedAt: null, + deletedAt: null, } - } catch (error) { - console.error('Error fetching tasks:', error) - } finally { - setIsLoading(false) - } - } - - const addTaskOptimistically = (taskData: { - prompt: string - repoUrl: string - selectedAgent: string - selectedModel: string - installDependencies: boolean - maxDuration: number - }) => { - const id = generateId() - const optimisticTask: Task = { - id, - userId: 'temp', // Temporary value, will be replaced by server - prompt: taskData.prompt, - title: null, - repoUrl: taskData.repoUrl, - envId: null, - selectedAgent: taskData.selectedAgent, - selectedModel: taskData.selectedModel, - installDependencies: taskData.installDependencies, - maxDuration: taskData.maxDuration, - keepAlive: false, - enableBrowser: false, - mode: 'default', - status: 'pending', - progress: 0, - logs: [], - error: null, - branchName: null, - sandboxId: null, - sandboxSessionId: null, - sandboxCwd: null, - sandboxMode: null, - agentSessionId: null, - sandboxUrl: null, - previewUrl: null, - mcpServerIds: null, - prUrl: null, - prNumber: null, - prStatus: null, - prMergeCommitSha: null, - createdAt: Date.now(), - updatedAt: Date.now(), - completedAt: null, - deletedAt: null, - } - - // Add the optimistic task to the beginning of the tasks array - setTasks((prevTasks) => [optimisticTask, ...prevTasks]) - - return { id, optimisticTask } - } + prependTask(optimisticTask) + return { id, optimisticTask } + }, + [], + ) const closeSidebar = () => { - updateSidebarOpen(false, false) // Don't save to cookie for mobile backdrop clicks + updateSidebarOpen(false, false) } const handleMouseDown = (e: React.MouseEvent) => { @@ -272,27 +178,16 @@ export function AppLayout({ children, initialSidebarWidth, initialSidebarOpen, i useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!isResizing) return - const newWidth = e.clientX - const minWidth = 200 - const maxWidth = 600 - - if (newWidth >= minWidth && newWidth <= maxWidth) { - updateSidebarWidth(newWidth) - } - } - - const handleMouseUp = () => { - setIsResizing(false) + if (newWidth >= 200 && newWidth <= 600) updateSidebarWidth(newWidth) } - + const handleMouseUp = () => setIsResizing(false) if (isResizing) { document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) document.body.style.cursor = 'col-resize' document.body.style.userSelect = 'none' } - return () => { document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) @@ -304,7 +199,7 @@ export function AppLayout({ children, initialSidebarWidth, initialSidebarOpen, i return ( - {/* Backdrop - Mobile Only */} {isSidebarOpen &&
} - {/* Sidebar */}
-
- {isLoading ? : } +
+
- {/* Resize Handle - Desktop Only, when sidebar is open */}