From 5071a9d9c503ab565a67caa0d968c869af1be54e Mon Sep 17 00:00:00 2001 From: enzopang Date: Tue, 19 May 2026 19:58:22 +0800 Subject: [PATCH 01/29] docs: handoff lessons from feat/multi-infra for stateful-infra Capture architecture, lifecycle, merge, and cherry-pick guidance before implementing stateful sandbox on main without the SCF dual-backend switch. Co-authored-by: Cursor --- docs/stateful-infra-handoff.md | 157 +++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 docs/stateful-infra-handoff.md diff --git a/docs/stateful-infra-handoff.md b/docs/stateful-infra-handoff.md new file mode 100644 index 0000000..2fda10c --- /dev/null +++ b/docs/stateful-infra-handoff.md @@ -0,0 +1,157 @@ +# Stateful Infra 交接:来自 `feat/multi-infra` 的经验 + +> **读者**:`feature/stateful-infra` 分支上的实现者。 +> **归档分支**:`feat/multi-infra` @ `5ff49a9`(tag:`archive/feat-multi-infra`) +> **新分支基线**:`main` @ `68e6871` 及之后 —— **不含** SCF/AGS 双开关代码。 +> **日期**:2026-05-19 + +--- + +## 1. 我们为什么换路线 + +| 旧路线 (`feat/multi-infra`) | 新路线 (`feature/stateful-infra`) | +|-----------------------------|----------------------------------| +| `SANDBOX_BACKEND=scf\|ags` 同树维护 | 从 `main` 切出,**只服务有状态沙箱** | +| 每合一次 `main` 都要解 agent/tasks 冲突 | SCF 留在 `main`,本分支不背零回归 | +| 路由里大量 `if (backend === 'ags')` | 产品层叫 **stateful sandbox**,不必暴露 AGS/Talos 品牌名 | +| 追 61 commits 体感「永远合不完」 | 按周/按 tag 从 `main` cherry-pick 共享层(鉴权、Task、DB) | + +**结论**:双 infra **开关**适合「同一 release 里两种后端」;你们下一步是 **TRW 最新有状态玩法 + manager-node 控制面 + e2b 数据面** → **单独分支更对**。 + +--- + +## 2. 在 `feat/multi-infra` 上做过什么(可当考古索引) + +### 2.1 交付物 + +- `SandboxProvider` 抽象:`acquire` → `prepare` → `release`,外加 `createMcpClient` / `getPreviewBaseUrl` / `deleteConversation` +- `scf-provider`:薄包装 `scf-sandbox-manager`(**新分支不要 port SCF**) +- `ags-provider` + `sandbox/ags/*`:manager-node 启停实例 + gateway `request()` + `e2b-native-client` 读写跑命令 +- `scripts/verify-ags-e2e.ts` + `pnpm verify:ags`:13 步探针(可改名为 stateful 探针) +- 合入 `origin/main` 后的冲突决议:以 **main 业务为准**,AGS 逻辑只留在 provider/ags 块内 + +### 2.2 刻意没收口的(留给 stateful 分支重做) + +- `tasks.ts` 里残留的 backend 分支(计划 Phase E:收到 Provider 方法) +- Talos(仅占 env 枚举) +- MCP / tool-override **按 backend 拆文件**(P2/P2b 已取消,见下) + +--- + +## 3. 架构思考(新分支应继承) + +### 3.1 两层控制面(不必在 API 里写 AGS/Talos) + +| 层 | 职责 | 频率 | 典型 API | +|----|------|------|----------| +| **模板 / 规格** | Tool(SDT)、镜像、资源规格 | 部署期 | manager-node `CreateTool` / 部署脚本 | +| **运行态实例** | 某次对话/任务用的沙箱进程 | 按需 | manager-node Start/Describe/Stop + gateway | + +上层统一叫 **stateful sandbox** 即可;实现仍是 CloudBase AGS(及未来 Talos),**控制面走 manager-node**,不要在每个路由名里绑 `ags`。 + +### 3.2 数据面:e2b SDK + +- **控制面**:创建/复用/停止实例、查状态 → **manager-node**(`@cloudbase/manager-node`,AGS 服务 `ags` API 版本 `2025-09-20`)。 +- **数据面**:读文件、写文件、exec/bash → **e2b SDK**(归档分支里 `ags/e2b-native-client.ts` 已验证可行)。 +- **TRW 网关**:`instance.request('/api/workspace/*')` 做凭证注入、workspace init;与 SCF 的 `/api/session/*` **不是一套协议**,新分支只跟 **master TRW `/api/workspace/*`**。 + +### 3.3 生命周期(上层只认三阶段) + +``` +acquire(ctx) // 拿到/复用实例(shared vs isolated 由 WORKSPACE_ISOLATION 等决定) + ↓ +prepare(inst) // 注凭证、workspace init、定 cwd / coding scope(预览前是否需要单独再 init 要想清楚) + ↓ +[agent: MCP + request + e2b] + ↓ +release(inst) // 归档/快照/推 git(跟 TRW 约定一致) + ↓ +destroy / deleteConversation // 删 task、TTL、停实例 +``` + +**Tool(SDT)**:环境级,**部署脚本创建一次**;Provider **只读** `STATEFUL_TOOL_ID`(或你们最终 env 名),不要在每个 task `acquire` 里建 Tool。 + +**关键教训(来自 SCF 零回归)**: + +- **预览 health / preview-url(SSE)** 不要和 **workspace 冷启动** 绑在同一条 `prepare` 链上,否则和 Vite 抢 `session/init` 类竞态。 +- **Agent 对话** 可以 `prepare`(health + init);**纯预览轮询** 应尽量少动实例状态。 +- **save-file**:写盘成功后异步持久化,**不阻塞** HTTP 响应。 + +### 3.4 Provider 接口仍值得用(但单 backend) + +归档分支的 `packages/server/src/sandbox/provider/types.ts` 可直接当 **stateful 版类型草图**: + +- 删掉 `scf` / `talos` 联合,只保留一个 `backend: 'stateful'` 或不暴露 backend。 +- `SandboxInstance`:`id`(instanceId)、`templateId`(toolId)、`baseUrl`、`request()`、`getAuthHeaders()`。 +- **`meta` 黑盒**:上层禁止读 `functionName` / 旧 SCF 字段。 + +### 3.5 设计决策:不先拆 shared MCP(P2/P2b 取消) + +> 「任何环节都能分叉替换」> 「共用实现 + 到处 if」。 + +- SCF 的 `sandbox-mcp-proxy.ts` / `tool-override.ts` **不要**为了 stateful 先大 refactor。 +- Stateful 写 **独立的** `stateful-mcp-client.ts`(可参考 `ags-mcp-client.ts`),稳定后再抽 `shared/`。 +- 避免在合 `main` 时动 SCF 热路径。 + +--- + +## 4. 合主干与协作教训 + +1. **热点文件**:`cloudbase-agent.service.ts`、`tasks.ts` —— 与 `main` 同改必冲突;stateful 分支应 **少改 tasks**,逻辑进 Provider。 +2. **合 main 顺序**:先 commit WIP → merge `origin/main` → 冲突时 **SCF/main 业务优先**(对 stateful 分支则是 **main 共享层优先**)。 +3. **落后 61 commits 时**:干净树 merge 可能无冲突,**有 WIP 才爆** —— 控制 WIP 粒度。 +4. **配置**:敏感与泳道只写 `packages/server/.env`(注释说明),`.env.example` 只放非 secret 模板;**测试 Tool 与生产 Tool 分开**(曾用 `sdt-bjqg7iaw` vibecoding vs 主线 `sdt-987gpzk2`)。 +5. **验收分离**:stateful 探针(原 `verify:ags`)与 SCF 回归矩阵 **分开跑**,不要混 CI 默认路径。 + +--- + +## 5. 从归档分支「抄作业」清单 + +| 优先 | 路径 | 用途 | +|------|------|------| +| 高 | `sandbox/provider/types.ts` | 接口与 Context 分型 | +| 高 | `sandbox/ags/ags-provider.ts` | manager-node 生命周期(改名 stateful-provider) | +| 高 | `sandbox/ags/ags-mcp-client.ts` | workspace env + MCP in-process | +| 高 | `sandbox/ags/e2b-native-client.ts` | e2b 数据面 | +| 中 | `scripts/verify-ags-e2e.ts` | E2E 探针模板 | +| 中 | `sandbox/trw-deploy-adapter.ts` | TRW 部署相关(若仍适用) | +| 低 | `scf-provider.ts` | **仅对照,不 port** | +| 低 | `routes/tasks.ts` 里 AGS 块 | **仅作行为对照**,实现应重写进 Provider | + +```bash +# 查看归档实现 +git show archive/feat-multi-infra:packages/server/src/sandbox/provider/ags-provider.ts +git diff main..archive/feat-multi-infra -- packages/server/src/sandbox/ +``` + +--- + +## 6. `feature/stateful-infra` 建议的第一批任务 + +1. **定 env 名**:如 `STATEFUL_TOOL_ID`、`TCB_API_KEY`、`STATEFUL_GATEWAY_URL`(避免继续用 `AGS_*` 若产品不想暴露)。 +2. **实现 `StatefulSandboxProvider`**:`acquire` / `prepare` / `release` + `getExisting`。 +3. **agent 只调 Provider**:从 `cloudbase-agent.service.ts` 接入,**不**恢复 `scfSandboxManager` 直连。 +4. **tasks 最小改动**:preview、files、terminal 逐步迁到 Provider 方法;preview 遵守 §3.3 竞态教训。 +5. **引入/锁定 e2b SDK 版本**,与 TRW 镜像内 runtime 对齐。 +6. **文档**:本文件 + `.plans/stateful-infra-roadmap.md`(迭代计划放 `.plans/`,已 gitignore)。 + +--- + +## 7. 仍有效的验收思路(改成 stateful 语义) + +| # | 场景 | 通过标准 | +|---|------|----------| +| S1 | 新建 coding task + 对话 | instance 复用/创建日志清晰;workspace init 一次 | +| S2 | preview-url SSE | 无多余 init 竞态;Vite 就绪 | +| S3 | preview-health | 探活路径与 TRW master 一致(非 SCF scope/info) | +| S4 | 读写信 | e2b 或 gateway 文件 API 通畅 | +| S5 | 删 task | 停实例 + 清理策略符合产品 | +| S6 | `pnpm verify:stateful`(待改名) | 无人工点 UI 的自动化探针 | + +--- + +## 8. 一句话带走 + +**控制面 manager-node、数据面 e2b、协议 TRW workspace、生命周期 acquire/prepare/release;Tool 部署期、Instance 运行期;不要和 main 上的 SCF 共分支维护。** + +归档分支是 **付钱买的教训**;新分支 **重写路由、复用思路、复用 ags 目录里经过验证的那几份实现**。 From 1c9335560438dc40e3c6a3847c6331639713583f Mon Sep 17 00:00:00 2001 From: enzopang Date: Tue, 19 May 2026 20:00:34 +0800 Subject: [PATCH 02/29] chore: move stateful handoff to .plans, ignore planning dir Remove docs/stateful-infra-handoff.md; planning docs live under .plans/ only (gitignored per project convention). Co-authored-by: Cursor --- .gitignore | 1 + docs/stateful-infra-handoff.md | 157 --------------------------------- 2 files changed, 1 insertion(+), 157 deletions(-) delete mode 100644 docs/stateful-infra-handoff.md diff --git a/.gitignore b/.gitignore index 57f2539..0441680 100644 --- a/.gitignore +++ b/.gitignore @@ -90,5 +90,6 @@ CodeBuddy Code_decompiled CodeBuddy Code_files decompiled decompiled-ui +.plans/ # opencode project-level config (auto-generated tool overrides) .opencode/tools/ diff --git a/docs/stateful-infra-handoff.md b/docs/stateful-infra-handoff.md deleted file mode 100644 index 2fda10c..0000000 --- a/docs/stateful-infra-handoff.md +++ /dev/null @@ -1,157 +0,0 @@ -# Stateful Infra 交接:来自 `feat/multi-infra` 的经验 - -> **读者**:`feature/stateful-infra` 分支上的实现者。 -> **归档分支**:`feat/multi-infra` @ `5ff49a9`(tag:`archive/feat-multi-infra`) -> **新分支基线**:`main` @ `68e6871` 及之后 —— **不含** SCF/AGS 双开关代码。 -> **日期**:2026-05-19 - ---- - -## 1. 我们为什么换路线 - -| 旧路线 (`feat/multi-infra`) | 新路线 (`feature/stateful-infra`) | -|-----------------------------|----------------------------------| -| `SANDBOX_BACKEND=scf\|ags` 同树维护 | 从 `main` 切出,**只服务有状态沙箱** | -| 每合一次 `main` 都要解 agent/tasks 冲突 | SCF 留在 `main`,本分支不背零回归 | -| 路由里大量 `if (backend === 'ags')` | 产品层叫 **stateful sandbox**,不必暴露 AGS/Talos 品牌名 | -| 追 61 commits 体感「永远合不完」 | 按周/按 tag 从 `main` cherry-pick 共享层(鉴权、Task、DB) | - -**结论**:双 infra **开关**适合「同一 release 里两种后端」;你们下一步是 **TRW 最新有状态玩法 + manager-node 控制面 + e2b 数据面** → **单独分支更对**。 - ---- - -## 2. 在 `feat/multi-infra` 上做过什么(可当考古索引) - -### 2.1 交付物 - -- `SandboxProvider` 抽象:`acquire` → `prepare` → `release`,外加 `createMcpClient` / `getPreviewBaseUrl` / `deleteConversation` -- `scf-provider`:薄包装 `scf-sandbox-manager`(**新分支不要 port SCF**) -- `ags-provider` + `sandbox/ags/*`:manager-node 启停实例 + gateway `request()` + `e2b-native-client` 读写跑命令 -- `scripts/verify-ags-e2e.ts` + `pnpm verify:ags`:13 步探针(可改名为 stateful 探针) -- 合入 `origin/main` 后的冲突决议:以 **main 业务为准**,AGS 逻辑只留在 provider/ags 块内 - -### 2.2 刻意没收口的(留给 stateful 分支重做) - -- `tasks.ts` 里残留的 backend 分支(计划 Phase E:收到 Provider 方法) -- Talos(仅占 env 枚举) -- MCP / tool-override **按 backend 拆文件**(P2/P2b 已取消,见下) - ---- - -## 3. 架构思考(新分支应继承) - -### 3.1 两层控制面(不必在 API 里写 AGS/Talos) - -| 层 | 职责 | 频率 | 典型 API | -|----|------|------|----------| -| **模板 / 规格** | Tool(SDT)、镜像、资源规格 | 部署期 | manager-node `CreateTool` / 部署脚本 | -| **运行态实例** | 某次对话/任务用的沙箱进程 | 按需 | manager-node Start/Describe/Stop + gateway | - -上层统一叫 **stateful sandbox** 即可;实现仍是 CloudBase AGS(及未来 Talos),**控制面走 manager-node**,不要在每个路由名里绑 `ags`。 - -### 3.2 数据面:e2b SDK - -- **控制面**:创建/复用/停止实例、查状态 → **manager-node**(`@cloudbase/manager-node`,AGS 服务 `ags` API 版本 `2025-09-20`)。 -- **数据面**:读文件、写文件、exec/bash → **e2b SDK**(归档分支里 `ags/e2b-native-client.ts` 已验证可行)。 -- **TRW 网关**:`instance.request('/api/workspace/*')` 做凭证注入、workspace init;与 SCF 的 `/api/session/*` **不是一套协议**,新分支只跟 **master TRW `/api/workspace/*`**。 - -### 3.3 生命周期(上层只认三阶段) - -``` -acquire(ctx) // 拿到/复用实例(shared vs isolated 由 WORKSPACE_ISOLATION 等决定) - ↓ -prepare(inst) // 注凭证、workspace init、定 cwd / coding scope(预览前是否需要单独再 init 要想清楚) - ↓ -[agent: MCP + request + e2b] - ↓ -release(inst) // 归档/快照/推 git(跟 TRW 约定一致) - ↓ -destroy / deleteConversation // 删 task、TTL、停实例 -``` - -**Tool(SDT)**:环境级,**部署脚本创建一次**;Provider **只读** `STATEFUL_TOOL_ID`(或你们最终 env 名),不要在每个 task `acquire` 里建 Tool。 - -**关键教训(来自 SCF 零回归)**: - -- **预览 health / preview-url(SSE)** 不要和 **workspace 冷启动** 绑在同一条 `prepare` 链上,否则和 Vite 抢 `session/init` 类竞态。 -- **Agent 对话** 可以 `prepare`(health + init);**纯预览轮询** 应尽量少动实例状态。 -- **save-file**:写盘成功后异步持久化,**不阻塞** HTTP 响应。 - -### 3.4 Provider 接口仍值得用(但单 backend) - -归档分支的 `packages/server/src/sandbox/provider/types.ts` 可直接当 **stateful 版类型草图**: - -- 删掉 `scf` / `talos` 联合,只保留一个 `backend: 'stateful'` 或不暴露 backend。 -- `SandboxInstance`:`id`(instanceId)、`templateId`(toolId)、`baseUrl`、`request()`、`getAuthHeaders()`。 -- **`meta` 黑盒**:上层禁止读 `functionName` / 旧 SCF 字段。 - -### 3.5 设计决策:不先拆 shared MCP(P2/P2b 取消) - -> 「任何环节都能分叉替换」> 「共用实现 + 到处 if」。 - -- SCF 的 `sandbox-mcp-proxy.ts` / `tool-override.ts` **不要**为了 stateful 先大 refactor。 -- Stateful 写 **独立的** `stateful-mcp-client.ts`(可参考 `ags-mcp-client.ts`),稳定后再抽 `shared/`。 -- 避免在合 `main` 时动 SCF 热路径。 - ---- - -## 4. 合主干与协作教训 - -1. **热点文件**:`cloudbase-agent.service.ts`、`tasks.ts` —— 与 `main` 同改必冲突;stateful 分支应 **少改 tasks**,逻辑进 Provider。 -2. **合 main 顺序**:先 commit WIP → merge `origin/main` → 冲突时 **SCF/main 业务优先**(对 stateful 分支则是 **main 共享层优先**)。 -3. **落后 61 commits 时**:干净树 merge 可能无冲突,**有 WIP 才爆** —— 控制 WIP 粒度。 -4. **配置**:敏感与泳道只写 `packages/server/.env`(注释说明),`.env.example` 只放非 secret 模板;**测试 Tool 与生产 Tool 分开**(曾用 `sdt-bjqg7iaw` vibecoding vs 主线 `sdt-987gpzk2`)。 -5. **验收分离**:stateful 探针(原 `verify:ags`)与 SCF 回归矩阵 **分开跑**,不要混 CI 默认路径。 - ---- - -## 5. 从归档分支「抄作业」清单 - -| 优先 | 路径 | 用途 | -|------|------|------| -| 高 | `sandbox/provider/types.ts` | 接口与 Context 分型 | -| 高 | `sandbox/ags/ags-provider.ts` | manager-node 生命周期(改名 stateful-provider) | -| 高 | `sandbox/ags/ags-mcp-client.ts` | workspace env + MCP in-process | -| 高 | `sandbox/ags/e2b-native-client.ts` | e2b 数据面 | -| 中 | `scripts/verify-ags-e2e.ts` | E2E 探针模板 | -| 中 | `sandbox/trw-deploy-adapter.ts` | TRW 部署相关(若仍适用) | -| 低 | `scf-provider.ts` | **仅对照,不 port** | -| 低 | `routes/tasks.ts` 里 AGS 块 | **仅作行为对照**,实现应重写进 Provider | - -```bash -# 查看归档实现 -git show archive/feat-multi-infra:packages/server/src/sandbox/provider/ags-provider.ts -git diff main..archive/feat-multi-infra -- packages/server/src/sandbox/ -``` - ---- - -## 6. `feature/stateful-infra` 建议的第一批任务 - -1. **定 env 名**:如 `STATEFUL_TOOL_ID`、`TCB_API_KEY`、`STATEFUL_GATEWAY_URL`(避免继续用 `AGS_*` 若产品不想暴露)。 -2. **实现 `StatefulSandboxProvider`**:`acquire` / `prepare` / `release` + `getExisting`。 -3. **agent 只调 Provider**:从 `cloudbase-agent.service.ts` 接入,**不**恢复 `scfSandboxManager` 直连。 -4. **tasks 最小改动**:preview、files、terminal 逐步迁到 Provider 方法;preview 遵守 §3.3 竞态教训。 -5. **引入/锁定 e2b SDK 版本**,与 TRW 镜像内 runtime 对齐。 -6. **文档**:本文件 + `.plans/stateful-infra-roadmap.md`(迭代计划放 `.plans/`,已 gitignore)。 - ---- - -## 7. 仍有效的验收思路(改成 stateful 语义) - -| # | 场景 | 通过标准 | -|---|------|----------| -| S1 | 新建 coding task + 对话 | instance 复用/创建日志清晰;workspace init 一次 | -| S2 | preview-url SSE | 无多余 init 竞态;Vite 就绪 | -| S3 | preview-health | 探活路径与 TRW master 一致(非 SCF scope/info) | -| S4 | 读写信 | e2b 或 gateway 文件 API 通畅 | -| S5 | 删 task | 停实例 + 清理策略符合产品 | -| S6 | `pnpm verify:stateful`(待改名) | 无人工点 UI 的自动化探针 | - ---- - -## 8. 一句话带走 - -**控制面 manager-node、数据面 e2b、协议 TRW workspace、生命周期 acquire/prepare/release;Tool 部署期、Instance 运行期;不要和 main 上的 SCF 共分支维护。** - -归档分支是 **付钱买的教训**;新分支 **重写路由、复用思路、复用 ags 目录里经过验证的那几份实现**。 From c6c01e20b6d8e675e45b26b243dd2bf0a1a107b0 Mon Sep 17 00:00:00 2001 From: enzopang Date: Tue, 19 May 2026 20:01:17 +0800 Subject: [PATCH 03/29] chore: gitignore local tooling dirs (.cursor, ccc, codegraph) Co-authored-by: Cursor --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index 0441680..b65d33b 100644 --- a/.gitignore +++ b/.gitignore @@ -91,5 +91,14 @@ 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/ From 3b32f412787710ca6d6e93d5d26eae9a026575b2 Mon Sep 17 00:00:00 2001 From: enzopang Date: Thu, 21 May 2026 15:22:00 +0800 Subject: [PATCH 04/29] feat(stateful): stateful sandbox provider and coding preview path Replace SCF/legacy AGS paths with stateful gateway + TRW tool-override. Add preview-url SSE, OVC preview proxy (strip gzip Content-Encoding), and faster task polling so coding preview loads without waiting for stream end. Co-authored-by: Cursor --- .env.example | 21 +- .nvmrc | 1 + README.md | 5 +- docs/architecture.md | 6 +- docs/setup.md | 5 +- package.json | 4 + packages/server/package.json | 11 +- .../server/scripts/stop-stateful-instances.ts | 99 ++ .../test-opencode-coding-preview-e2e.mts | 313 ------ .../scripts/test-opencode-sandbox-e2e.mts | 150 --- .../scripts/update-stateful-tool-image.ts | 71 ++ .../server/scripts/verify-stateful-e2e.ts | 189 ++++ .../src/agent/cloudbase-agent.service.ts | 230 ++-- .../server/src/agent/runtime/base-runtime.ts | 236 ++--- .../src/agent/runtime/opencode-acp-runtime.ts | 11 +- .../server/src/db/cloudbase/repositories.ts | 16 +- packages/server/src/db/schema.ts | 1 + packages/server/src/db/types.ts | 29 +- packages/server/src/index.ts | 8 +- .../cloudbase-mcp-inject-credentials.test.ts | 44 +- packages/server/src/lib/cloudbase-mcp.ts | 13 +- packages/server/src/lib/sandbox-config.ts | 127 ++- packages/server/src/routes/acp.ts | 2 +- packages/server/src/routes/admin.ts | 16 + packages/server/src/routes/auth.ts | 21 +- packages/server/src/routes/cloudbase-mcp.ts | 10 +- packages/server/src/routes/tasks.ts | 994 ++++++++---------- .../sandbox/__tests__/preview-proxy.test.ts | 13 + .../server/src/sandbox/acquire-context.ts | 26 + .../src/sandbox/ensure-stateful-tool.ts | 171 +++ packages/server/src/sandbox/git-archive.ts | 19 +- packages/server/src/sandbox/git-personal.ts | 2 +- packages/server/src/sandbox/index.ts | 45 +- packages/server/src/sandbox/preview-proxy.ts | 142 +++ .../server/src/sandbox/preview-ws-proxy.ts | 102 ++ .../server/src/sandbox/provider/factory.ts | 18 + .../src/sandbox/provider/stateful-provider.ts | 550 ++++++++++ packages/server/src/sandbox/provider/types.ts | 151 +++ .../server/src/sandbox/sandbox-mcp-proxy.ts | 273 ----- .../server/src/sandbox/scf-sandbox-manager.ts | 710 ------------- .../src/sandbox/stateful/e2b-native-client.ts | 120 +++ .../sandbox/stateful/stateful-mcp-client.ts | 820 +++++++++++++++ packages/server/src/sandbox/task-sandbox.ts | 179 ++++ packages/server/src/sandbox/tool-override.ts | 86 +- .../server/src/sandbox/trw-deploy-adapter.ts | 127 +++ .../server/src/sandbox/wait-vite-ready.ts | 86 ++ packages/server/src/sandbox/ws-auth.ts | 66 ++ .../server/src/util/skill-loader-override.ts | 47 +- .../src/components/chat/browser-controls.tsx | 9 +- packages/web/src/components/logs-pane.tsx | 47 +- packages/web/src/components/task-details.tsx | 53 +- .../web/src/components/task-page-client.tsx | 1 + packages/web/src/components/task-sidebar.tsx | 55 +- packages/web/src/components/terminal.tsx | 41 +- packages/web/src/hooks/use-preview-bridge.ts | 4 +- packages/web/src/hooks/use-task.ts | 5 +- packages/web/src/pages/LoginPage.tsx | 3 +- .../web/src/pages/admin/settings-page.tsx | 139 ++- pnpm-lock.yaml | 236 ++++- 59 files changed, 4401 insertions(+), 2578 deletions(-) create mode 100644 .nvmrc create mode 100644 packages/server/scripts/stop-stateful-instances.ts delete mode 100644 packages/server/scripts/test-opencode-coding-preview-e2e.mts delete mode 100644 packages/server/scripts/test-opencode-sandbox-e2e.mts create mode 100644 packages/server/scripts/update-stateful-tool-image.ts create mode 100644 packages/server/scripts/verify-stateful-e2e.ts create mode 100644 packages/server/src/sandbox/__tests__/preview-proxy.test.ts create mode 100644 packages/server/src/sandbox/acquire-context.ts create mode 100644 packages/server/src/sandbox/ensure-stateful-tool.ts create mode 100644 packages/server/src/sandbox/preview-proxy.ts create mode 100644 packages/server/src/sandbox/preview-ws-proxy.ts create mode 100644 packages/server/src/sandbox/provider/factory.ts create mode 100644 packages/server/src/sandbox/provider/stateful-provider.ts create mode 100644 packages/server/src/sandbox/provider/types.ts delete mode 100644 packages/server/src/sandbox/sandbox-mcp-proxy.ts delete mode 100644 packages/server/src/sandbox/scf-sandbox-manager.ts create mode 100644 packages/server/src/sandbox/stateful/e2b-native-client.ts create mode 100644 packages/server/src/sandbox/stateful/stateful-mcp-client.ts create mode 100644 packages/server/src/sandbox/task-sandbox.ts create mode 100644 packages/server/src/sandbox/trw-deploy-adapter.ts create mode 100644 packages/server/src/sandbox/wait-vite-ready.ts create mode 100644 packages/server/src/sandbox/ws-auth.ts diff --git a/.env.example b/.env.example index 2dbf89a..206546b 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ # ==================== Required ==================== # Session 加密密钥(init.sh 会自动生成) +# 32 bytes as base64 (openssl rand -base64 32). Do NOT reuse ENCRYPTION_KEY hex here. JWE_SECRET= ENCRYPTION_KEY= @@ -28,6 +29,7 @@ TCB_SECRET_KEY= # 用户环境模式:shared(默认)或 isolated # TCB_PROVISION_MODE=shared +# SANDBOX_INSTANCE_MODE=shared # shared = one AGS instance per env; isolated = one per task # ==================== CodeBuddy Auth ==================== @@ -46,17 +48,16 @@ TCB_SECRET_KEY= MAX_MESSAGES_PER_DAY=50 MAX_SANDBOX_DURATION=300 -# ==================== SCF Sandbox ==================== +# ==================== Stateful sandbox (feature/stateful-infra) ==================== +# Local dev: Node 22.x only (see README / docs/setup.md — better-sqlite3 native ABI). +# Copy server secrets to packages/server/.env — see .plans/stateful-env-checklist.md -# 镜像类型:personal(默认) -# SCF_SANDBOX_IMAGE_TYPE=personal -# SCF_SANDBOX_IMAGE_URI= -# SCF_SANDBOX_IMAGE_PORT=9000 - -# 工作空间隔离模式: -# shared - 同一 envId 下所有 task 共享 SCF 容器实例,通过目录隔离工作区 -# isolated - 每个 task 独立 SCF session,完全隔离文件系统(默认) -WORKSPACE_ISOLATION=isolated +# TCB_API_KEY= # gateway JWT (sandbox apikey create) +# STATEFUL_TOOL_ID= # existing vibecoding SDT; skips CreateTool +# STATEFUL_SANDBOX_IMAGE= # required only when creating a new tool +# STATEFUL_GATEWAY_URL= # optional; default https://{TCB_ENV_ID}.api.tcloudbasegateway.com/v1/sandbox/- +# STATEFUL_SANDBOX_ID= # optional: pin a running instance (debug) +# STATEFUL_MINIPROGRAM_FEATURE=true # when TRW exposes /api/jobs/miniprogram-deploy # Vite 原生错误 overlay 开关(创建沙箱时注入,需要重建沙箱生效) # 设为 false 可关闭预览中 Vite 自带的全屏错误遮罩,改由平台侧 banner 展示构建错误 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/README.md b/README.md index f643666..7f80008 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,9 @@ ### 前置条件 开始前请确认: -- Node.js >= 18 -- Docker 已安装并启动 +- **Node.js 22.x**(必需;`better-sqlite3` 原生模块与 server build target 对齐 **node22**,勿用 Node 23+ 或 brew 默认 Node 26)。推荐:`mise use node@22` 或 `nvm use`(根目录 `.nvmrc` 为 `22`) +- pnpm 10+ +- Docker 已安装并启动(stateful 分支本地 dev **不**需要本机 TRW;沙箱在云端) - 已准备 CloudBase 环境和腾讯云 API 密钥 - 已准备 CodeBuddy API Key 或 OAuth 配置 diff --git a/docs/architecture.md b/docs/architecture.md index 2f2a8a4..b8441ab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -213,9 +213,11 @@ flowchart LR | Capability | Endpoint | Description | | --- | --- | --- | -| File System | `/e2b-compatible/files` | 文件读写(兼容 e2b 协议) | +| File System | `/api/tools/read`, `write`, `files_upload`, `files_download` | 文件读写 | | Bash | `/api/tools/bash` | Shell 命令执行 | -| Git Push | `/api/tools/git_push` | 将工作区变更推送到远端 | +| Web Terminal | `/preview/7681/` (ttyd) | 浏览器终端 | +| Vite preview | `/preview/5173/` | 开发服务器(vibecoding lazy ensure) | +| Git Push | `POST /api/extend/git_push` | 将工作区变更推送到远端(`ENABLE_GIT_ARCHIVE`) | | MCP Server | In-memory transport | CloudBase 工具和部署工具 | | Health | `/health` | 容器健康检查 | diff --git a/docs/setup.md b/docs/setup.md index b68034d..1dd9c7e 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 已安装并已启动(**stateful 分支**:本地只跑 OVC,沙箱连云端 AGS+TRW,无需本机 `tcb-sandbox serve`) - 腾讯云账号,且已准备 CloudBase 环境 - 可用的腾讯云 API 密钥(`SecretId` / `SecretKey`) - 至少一种 CodeBuddy 认证方式: diff --git a/package.json b/package.json index b9ab5e1..f0d667f 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", diff --git a/packages/server/package.json b/packages/server/package.json index a3b3dce..d0a2a88 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", @@ -14,7 +17,10 @@ "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/stop-stateful-instances.ts b/packages/server/scripts/stop-stateful-instances.ts new file mode 100644 index 0000000..69824af --- /dev/null +++ b/packages/server/scripts/stop-stateful-instances.ts @@ -0,0 +1,99 @@ +/** + * Stop active AGS sandbox instances for the configured stateful tool (shared-env cleanup). + * Loads packages/server/.env like verify-stateful-e2e.ts. + * + * Usage: pnpm stop:stateful-instances + */ +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') +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 callAgsManagerApi(action: string, param: Record) { + const managerModule = await import('@cloudbase/manager-node') + // @ts-expect-error manager-node ships utils without types + const managerUtilsModule = await import('@cloudbase/manager-node/lib/utils') + const CloudBase = ((managerModule as any).default || managerModule) as any + const CloudService = ((managerUtilsModule as any).CloudService || + (managerUtilsModule as any).default?.CloudService) as any + + const secretId = + process.env.TCB_SECRET_ID || process.env.TENCENTCLOUD_SECRET_ID || process.env.TENCENT_SECRET_ID || '' + const secretKey = + process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_SECRET_KEY || process.env.TENCENT_SECRET_KEY || '' + const token = process.env.TCB_TOKEN || process.env.TENCENTCLOUD_SESSIONTOKEN || '' + const managerEnvId = process.env.TCB_ENV_ID || '' + + if (!secretId || !secretKey || !managerEnvId) { + throw new Error('TCB_ENV_ID and TCB_SECRET_ID/KEY are required') + } + + const app = new CloudBase({ secretId, secretKey, token, envId: managerEnvId }) + const agsService = new CloudService(app.context, 'ags', '2025-09-20') + return (await agsService.request(action, param)) as Record +} + +async function main() { + const toolId = process.env.STATEFUL_TOOL_ID || process.env.STATEFUL_SANDBOX_TOOL_ID || '' + if (!toolId) { + console.error('Set STATEFUL_TOOL_ID in packages/server/.env') + process.exit(1) + } + + const list = await callAgsManagerApi('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 callAgsManagerApi('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-opencode-coding-preview-e2e.mts b/packages/server/scripts/test-opencode-coding-preview-e2e.mts deleted file mode 100644 index 6be6922..0000000 --- a/packages/server/scripts/test-opencode-coding-preview-e2e.mts +++ /dev/null @@ -1,313 +0,0 @@ -#!/usr/bin/env tsx -/** - * E2E: OpenCode runtime + coding mode + preview + 第二轮修改 - * - * 验证目标: - * Round 1: hi 激活 → 沙箱初始化 + coding 模板 + Vite dev server 启动 - * → previewUrl 标记 ready,预览路径可访问 - * Round 2: 让 agent 修改 src/App.tsx 中某个文案 → 用 read+write 工具 - * → 重新拉预览,验证内容变更体现在 dev server 输出中 - * - * 用法: - * cd packages/server - * npx tsx --env-file=.env scripts/test-opencode-coding-preview-e2e.mts - */ - -import 'dotenv/config' -import { opencodeAcpRuntime } from '../src/agent/runtime/opencode-acp-runtime.js' -import { scfSandboxManager } from '../src/sandbox/scf-sandbox-manager.js' -import { getDb } from '../src/db/index.js' -import type { AgentCallbackMessage } from '@coder/shared' - -const envId = process.env.TCB_ENV_ID -if (!envId) { - console.error('TCB_ENV_ID not set') - process.exit(1) -} - -const conversationId = `opencode-coding-e2e-${Date.now()}` -const userId = 'e2e-coding-user' - -// 用一个有标记的 marker 字符串,便于在 round 2 中验证替换成功 -// 用普通可读词避免被模型内容审核误判 -const ROUND1_PROMPT = 'hi' -const TITLE_MARKER = `我的精彩应用` -const ROUND2_PROMPT = `请帮我把项目 index.html 的网页标题(title 标签)改成"${TITLE_MARKER}"。简单地说:用 read 工具读 index.html,然后用 edit 或 write 工具把 ... 之间的文字替换成"${TITLE_MARKER}",做完告诉我即可。` - -console.log('=== OpenCode coding mode preview e2e ===') -console.log(`envId = ${envId}`) -console.log(`conversationId = ${conversationId}`) -console.log(`titleMarker = ${TITLE_MARKER}\n`) - -// ─── Helpers ──────────────────────────────────────────────────────────── - -interface Recorded { - type: string - name?: string - preview?: string -} - -function makeRecorder(label: string): { - cb: (msg: AgentCallbackMessage) => Promise - events: Recorded[] -} { - const events: Recorded[] = [] - const cb = async (msg: AgentCallbackMessage): Promise => { - events.push({ - type: msg.type, - name: msg.name, - preview: typeof msg.content === 'string' ? msg.content.slice(0, 200) : undefined, - }) - if (msg.type === 'text' && msg.content) process.stdout.write(msg.content) - else if (msg.type === 'tool_use') - console.log(`\n[${label} tool_use ▶] ${msg.name} input=${JSON.stringify(msg.input).slice(0, 200)}`) - else if (msg.type === 'tool_result') - console.log( - `[${label} tool_result ◯] tool_use_id=${msg.tool_use_id} is_error=${msg.is_error} out=${(msg.content || '').slice(0, 200)}`, - ) - else if (msg.type === 'agent_phase') console.log(`\n[${label} phase] ${msg.phase}`) - else if (msg.type === 'error') console.log(`\n[${label} error] ${msg.content}`) - else if (msg.type === 'result') console.log(`\n[${label} result] ${(msg.content || '').slice(0, 200)}`) - } - return { cb, events } -} - -async function waitForResultOrError(events: Recorded[], maxMs = 240_000): Promise { - const start = Date.now() - while (Date.now() - start < maxMs) { - if (events.some((e) => e.type === 'result' || e.type === 'error')) return - await new Promise((r) => setTimeout(r, 500)) - } - throw new Error(`timed out after ${maxMs}ms waiting for result/error`) -} - -// ─── Pre-flight: ensure DB ready, mark task as coding mode ────────────── - -async function ensureTaskCodingMode(): Promise { - const now = Date.now() - try { - await getDb().tasks.create({ - id: conversationId, - userId, - prompt: ROUND1_PROMPT, - title: null, - repoUrl: null, - selectedAgent: 'opencode' as any, - selectedModel: 'mimo/mimo-v2.5-pro', - selectedRuntime: 'opencode-acp', - mode: 'coding', - installDependencies: false, - maxDuration: 30, - keepAlive: false, - enableBrowser: false, - status: 'pending', - progress: 0, - logs: '[]', - error: null, - branchName: null, - sandboxId: null, - sandboxSessionId: envId, - sandboxCwd: `/tmp/workspace/${envId}/${conversationId}`, - sandboxMode: 'shared', - agentSessionId: null, - sandboxUrl: null, - previewUrl: null, - prUrl: null, - prNumber: null, - prStatus: null, - prMergeCommitSha: null, - mcpServerIds: null, - createdAt: now, - updatedAt: now, - } as any) - console.log(`[setup] task created (coding mode)`) - } catch (e) { - console.log(`[setup] task.create failed (may already exist): ${(e as Error).message}`) - } -} - -await ensureTaskCodingMode() - -// ─── Round 1: hi → expect sandbox + coding init + dev server up ───────── - -console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') -console.log('Round 1: 激活 (hi) → 沙箱 + coding 项目 + dev server') -console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n') - -const r1 = makeRecorder('R1') -const r1Start = Date.now() -const r1Result = await opencodeAcpRuntime.chatStream(ROUND1_PROMPT, r1.cb, { - conversationId, - envId, - userId, - mode: 'coding', - model: 'mimo/mimo-v2.5-pro', -}) -console.log(`\n[R1] chatStream returned: turnId=${r1Result.turnId}`) - -await waitForResultOrError(r1.events, 240_000) -const r1Elapsed = ((Date.now() - r1Start) / 1000).toFixed(1) -console.log(`\n[R1] completed in ${r1Elapsed}s`) - -// ─── Round 1 sandbox & dev server validation ──────────────────────────── - -console.log('\n[R1] === sandbox + dev server validation ===') - -const sandbox = await scfSandboxManager.getOrCreate(conversationId, envId, { - mode: 'shared', - workspaceIsolation: 'shared', - isCodingMode: true, -}) - -let scopeInfo: any = null -try { - const headers = await sandbox.getAuthHeaders() - const res = await fetch(`${sandbox.baseUrl}/api/scope/info`, { headers }) - scopeInfo = await res.json().catch(() => null) - console.log(`[R1] scope/info: ${JSON.stringify(scopeInfo).slice(0, 400)}`) -} catch (e) { - console.log(`[R1] scope/info error: ${(e as Error).message}`) -} - -const workspace: string | undefined = scopeInfo?.workspace -const vitePort: number | undefined = scopeInfo?.vitePort -console.log(`[R1] workspace=${workspace} vitePort=${vitePort}`) - -// Check that index.html exists in workspace -let indexHtmlExists = false -let indexHtmlContent = '' -try { - const headers = await sandbox.getAuthHeaders() - const res = await fetch(`${sandbox.baseUrl}/api/tools/read`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...headers }, - body: JSON.stringify({ path: 'index.html' }), - }) - const data = (await res.json().catch(() => ({}))) as any - if (data.success && typeof data.result?.content === 'string') { - indexHtmlContent = data.result.content - indexHtmlExists = true - console.log(`[R1] index.html exists, size=${indexHtmlContent.length}`) - const titleMatch = indexHtmlContent.match(/]*>([^<]*)<\/title>/i) - console.log(`[R1] current : ${titleMatch?.[1] ?? '(no title)'}`) - } else { - console.log(`[R1] index.html read failed: ${JSON.stringify(data).slice(0, 200)}`) - } -} catch (e) { - console.log(`[R1] read index.html error: ${(e as Error).message}`) -} - -// Check dev server preview path - 用 scope/info 返回的真实 previewUrl -const previewPath: string = scopeInfo?.previewUrl || (vitePort ? `/preview/${vitePort}/` : '/preview/') -let previewOk = false -let previewSnippet = '' -try { - const headers = await sandbox.getAuthHeaders() - const res = await fetch(`${sandbox.baseUrl}${previewPath}`, { headers, signal: AbortSignal.timeout(15000) }) - previewSnippet = (await res.text()).slice(0, 600) - previewOk = res.ok && previewSnippet.length > 0 - console.log( - `[R1] ${previewPath} status=${res.status} ok=${previewOk} snippet="${previewSnippet.slice(0, 200).replace(/\n/g, ' ')}"`, - ) -} catch (e) { - console.log(`[R1] ${previewPath} error: ${(e as Error).message}`) -} - -// Check task previewUrl in DB -const task1 = await getDb().tasks.findById(conversationId) -console.log(`[R1] task.previewUrl = ${task1?.previewUrl}`) - -// ─── Round 2: ask agent to modify title ──────────────────────────────── - -console.log('\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') -console.log('Round 2: 修改 index.html title → 验证预览更新') -console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n') - -const r2 = makeRecorder('R2') -const r2Start = Date.now() -const r2Result = await opencodeAcpRuntime.chatStream(ROUND2_PROMPT, r2.cb, { - conversationId, - envId, - userId, - mode: 'coding', - model: 'mimo/mimo-v2.5-pro', -}) -console.log(`\n[R2] chatStream returned: turnId=${r2Result.turnId}`) - -await waitForResultOrError(r2.events, 300_000) -const r2Elapsed = ((Date.now() - r2Start) / 1000).toFixed(1) -console.log(`\n[R2] completed in ${r2Elapsed}s`) - -// ─── Round 2 validation: title changed in file & preview ─────────────── - -console.log('\n[R2] === modification validation ===') - -let r2IndexContent = '' -try { - const headers = await sandbox.getAuthHeaders() - const res = await fetch(`${sandbox.baseUrl}/api/tools/read`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...headers }, - body: JSON.stringify({ path: 'index.html' }), - }) - const data = (await res.json().catch(() => ({}))) as any - if (data.success && typeof data.result?.content === 'string') { - r2IndexContent = data.result.content - const titleMatch = r2IndexContent.match(/<title[^>]*>([^<]*)<\/title>/i) - console.log(`[R2] new <title>: ${titleMatch?.[1] ?? '(no title)'}`) - } -} catch (e) { - console.log(`[R2] read index.html error: ${(e as Error).message}`) -} - -const fileHasMarker = r2IndexContent.includes(TITLE_MARKER) - -// Wait a moment for vite HMR to pick up change, then re-fetch preview -await new Promise((r) => setTimeout(r, 3000)) - -let r2PreviewSnippet = '' -let previewHasMarker = false -try { - const headers = await sandbox.getAuthHeaders() - const res = await fetch(`${sandbox.baseUrl}${previewPath}`, { headers, signal: AbortSignal.timeout(15000) }) - r2PreviewSnippet = await res.text() - previewHasMarker = r2PreviewSnippet.includes(TITLE_MARKER) - const titleMatch = r2PreviewSnippet.match(/<title[^>]*>([^<]*)<\/title>/i) - console.log(`[R2] preview <title>: ${titleMatch?.[1] ?? '(no title)'} (status=${res.status})`) -} catch (e) { - console.log(`[R2] ${previewPath} error: ${(e as Error).message}`) -} - -// ─── Summary & assertions ────────────────────────────────────────────── - -const r1Counts: Record<string, number> = {} -for (const e of r1.events) r1Counts[e.type] = (r1Counts[e.type] ?? 0) + 1 -const r2Counts: Record<string, number> = {} -for (const e of r2.events) r2Counts[e.type] = (r2Counts[e.type] ?? 0) + 1 - -console.log('\n\n=========================================================') -console.log('FINAL SUMMARY') -console.log('=========================================================') -console.log(`R1 events: ${JSON.stringify(r1Counts)}`) -console.log(`R2 events: ${JSON.stringify(r2Counts)}`) -console.log() -console.log('R1 (sandbox + coding init):') -console.log(` workspace path: ${workspace ? 'PASS' : 'FAIL'} (${workspace})`) -console.log(` index.html exists: ${indexHtmlExists ? 'PASS' : 'FAIL'}`) -console.log(` /preview/ accessible: ${previewOk ? 'PASS' : 'FAIL'}`) -console.log(` task.previewUrl set: ${task1?.previewUrl ? 'PASS' : 'FAIL'} (${task1?.previewUrl})`) -console.log(` R1 has result event: ${r1Counts.result ? 'PASS' : 'FAIL'}`) -console.log() -console.log('R2 (file modification + preview):') -console.log(` R2 has result event: ${r2Counts.result ? 'PASS' : 'FAIL'}`) -console.log(` R2 used tools: ${r2Counts.tool_use ? `PASS (${r2Counts.tool_use})` : 'FAIL'}`) -console.log(` index.html contains marker: ${fileHasMarker ? 'PASS' : 'FAIL'}`) -console.log(` /preview/ contains marker: ${previewHasMarker ? 'PASS' : 'FAIL'}`) - -const r1Ok = !!workspace && indexHtmlExists && previewOk && (r1Counts.result ?? 0) > 0 -const r2Ok = (r2Counts.result ?? 0) > 0 && (r2Counts.tool_use ?? 0) > 0 && fileHasMarker -const overall = r1Ok && r2Ok - -console.log('\nOVERALL:', overall ? 'PASS ✓' : 'FAIL ✗') - -process.exit(overall ? 0 : 1) diff --git a/packages/server/scripts/test-opencode-sandbox-e2e.mts b/packages/server/scripts/test-opencode-sandbox-e2e.mts deleted file mode 100644 index a9410f5..0000000 --- a/packages/server/scripts/test-opencode-sandbox-e2e.mts +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env tsx -/** - * 沙箱隔离 e2e 测试(新架构 - tool override + env 注入) - * - * 验证链路: - * LLM → 调 write 工具 (被 ~/.config/opencode/tools/write.ts 覆盖) - * → 读 process.env.SANDBOX_BASE_URL + SANDBOX_AUTH_HEADERS_JSON - * → fetch 沙箱 /api/tools/write - * → 沙箱容器里真实写文件 - * - * 断言: - * 1. LLM 使用了 write 工具(名字就是 write,非 sbx_* 前缀) - * 2. 直接调沙箱 /api/tools/read 能读到预期内容(独立验证) - * 3. 本地文件系统未被污染 - * - * 用法: - * npx tsx --env-file=.env scripts/test-opencode-sandbox-e2e.mts - */ - -import 'dotenv/config' -import fs from 'node:fs' -import { opencodeAcpRuntime } from '../src/agent/runtime/opencode-acp-runtime.js' -import { scfSandboxManager } from '../src/sandbox/scf-sandbox-manager.js' -import type { AgentCallbackMessage } from '@coder/shared' - -const envId = process.env.TCB_ENV_ID -if (!envId) { - console.error('TCB_ENV_ID not set in env — cannot run sandbox e2e') - process.exit(1) -} - -const conversationId = 'sandbox-e2e-v2-' + Date.now() -const testFilename = `hello-sandbox-v2-${Date.now()}.txt` -const expectedContent = `hello from v2 ${Math.random().toString(36).slice(2, 10)}` -const sandboxRelPath = testFilename - -console.log(`[sandbox-e2e-v2] envId=${envId}`) -console.log(`[sandbox-e2e-v2] conversationId=${conversationId}`) -console.log(`[sandbox-e2e-v2] testFile (relative)=${sandboxRelPath}`) -console.log(`[sandbox-e2e-v2] expectedContent=${JSON.stringify(expectedContent)}\n`) - -// 清理潜在的本地同名文件(e2e 将验证本地不被污染) -const localMirror = `/tmp/${testFilename}` -try { - fs.unlinkSync(localMirror) -} catch { - /* noop */ -} - -interface RecordedEvent { - type: string - name?: string - content?: string -} -const events: RecordedEvent[] = [] - -const cb = async (msg: AgentCallbackMessage): Promise<void> => { - events.push({ - type: msg.type, - name: msg.name, - content: typeof msg.content === 'string' ? msg.content.slice(0, 180) : undefined, - }) - if (msg.type === 'text' && msg.content) process.stdout.write(msg.content) - else if (msg.type === 'tool_use') - console.log(`\n[tool_use ▶] name=${msg.name} id=${msg.id} input=${JSON.stringify(msg.input).slice(0, 200)}`) - else if (msg.type === 'tool_result') - console.log( - `[tool_result ◯] tool_use_id=${msg.tool_use_id} is_error=${msg.is_error} out=${(msg.content || '').slice(0, 200)}`, - ) - else if (msg.type === 'agent_phase') console.log(`\n[phase] ${msg.phase}`) - else if (msg.type === 'error') console.log(`\n[error] ${msg.content}`) - else if (msg.type === 'result') console.log(`\n[result] ${msg.content}`) -} - -console.log('[sandbox-e2e-v2] === starting chatStream (sandbox mode) ===') -const { turnId } = await opencodeAcpRuntime.chatStream( - `请使用 write 工具创建文件 ${sandboxRelPath}(使用相对路径),内容**完全等于**这一个字符串:${expectedContent}\n不要加引号、不要加 markdown、不要多余换行、不要添加任何其他文字。完成后简短告诉我已完成。`, - cb, - { - conversationId, - envId, - userId: 'e2e-user', - model: 'moonshot/kimi-k2-0905-preview', - }, -) -console.log(`\n[sandbox-e2e-v2] chatStream returned: turnId=${turnId}`) - -const startTime = Date.now() -while (Date.now() - startTime < 180_000) { - if (events.find((e) => e.type === 'result' || e.type === 'error')) break - await new Promise((r) => setTimeout(r, 500)) -} - -console.log('\n\n[sandbox-e2e-v2] === validation ===') - -const counts: Record<string, number> = {} -for (const e of events) counts[e.type] = (counts[e.type] ?? 0) + 1 -console.log('[sandbox-e2e-v2] event counts:', JSON.stringify(counts)) - -// 1. 确认 LLM 用了 write 工具 -const writeToolUses = events.filter((e) => e.type === 'tool_use' && e.name === 'write') -console.log(`[sandbox-e2e-v2] 'write' tool_use count: ${writeToolUses.length}`) - -// 2. 独立验证沙箱里的文件 -console.log('\n[sandbox-e2e-v2] querying sandbox /api/tools/read to verify...') -let sandboxReadOk = false -let sandboxReadContent = '' -try { - const sandbox = await scfSandboxManager.getOrCreate(conversationId, envId, { - mode: 'shared', - workspaceIsolation: 'shared', - }) - const headers = await sandbox.getAuthHeaders() - const res = await fetch(`${sandbox.baseUrl}/api/tools/read`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...headers }, - body: JSON.stringify({ path: sandboxRelPath }), - }) - const data = (await res.json().catch(() => ({}))) as { success?: boolean; result?: any; error?: string } - if (data.success && typeof data.result?.content === 'string') { - sandboxReadContent = data.result.content - sandboxReadOk = sandboxReadContent.includes(expectedContent) - console.log(`[sandbox-e2e-v2] sandbox file content = ${JSON.stringify(sandboxReadContent.slice(0, 200))}`) - } else { - console.log(`[sandbox-e2e-v2] sandbox read failed: ${data.error ?? JSON.stringify(data).slice(0, 200)}`) - } -} catch (e) { - console.log(`[sandbox-e2e-v2] sandbox read error: ${(e as Error).message}`) -} - -// 3. 本地文件系统干净 -const localExists = fs.existsSync(localMirror) -console.log(`[sandbox-e2e-v2] local ${localMirror} exists = ${localExists} (should be false)`) - -// ─── Assertions ───────────────────────────────────────────────────────────── -const hasText = (counts.text ?? 0) > 0 -const hasResult = (counts.result ?? 0) > 0 -const usedWrite = writeToolUses.length > 0 - -console.log('\n[sandbox-e2e-v2] assertions:') -console.log(` text events: ${hasText ? 'PASS' : 'FAIL'}`) -console.log(` result event: ${hasResult ? 'PASS' : 'FAIL'}`) -console.log(` LLM used 'write' tool: ${usedWrite ? 'PASS' : 'FAIL'} (count=${writeToolUses.length})`) -console.log(` sandbox file has content: ${sandboxReadOk ? 'PASS' : 'FAIL'}`) -console.log(` local NOT polluted: ${!localExists ? 'PASS' : 'FAIL'}`) - -const overall = hasText && hasResult && usedWrite && sandboxReadOk && !localExists -console.log(`\n[sandbox-e2e-v2] OVERALL: ${overall ? 'PASS' : 'FAIL'}`) - -process.exit(overall ? 0 : 1) 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..205238a --- /dev/null +++ b/packages/server/scripts/update-stateful-tool-image.ts @@ -0,0 +1,71 @@ +/** + * Point an existing stateful SDT at a new container image (after TRW rebuild). + * + * Usage (from packages/server, with .env 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' + +const here = dirname(fileURLToPath(import.meta.url)) +config({ path: resolve(here, '../.env') }) + +async function callAgs(action: string, param: Record<string, unknown>) { + const managerModule = await import('@cloudbase/manager-node') + const managerUtilsModule = await import('@cloudbase/manager-node/lib/utils') + const CloudBase = ((managerModule as { default?: unknown }).default || managerModule) as new (cfg: object) => { + context: object + } + const CloudService = ((managerUtilsModule as { CloudService?: unknown; default?: { CloudService?: unknown } }) + .CloudService || + (managerUtilsModule as { default?: { CloudService?: unknown } }).default?.CloudService) as new ( + ctx: object, + svc: string, + ver: string, + ) => { request: (a: string, p: object) => Promise<unknown> } + + const secretId = process.env.TCB_SECRET_ID || process.env.TENCENTCLOUD_SECRET_ID || '' + const secretKey = process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_SECRET_KEY || '' + const envId = process.env.TCB_ENV_ID || '' + if (!secretId || !secretKey || !envId) { + throw new Error('TCB_ENV_ID / TCB_SECRET_ID / TCB_SECRET_KEY required') + } + + const app = new CloudBase({ secretId, secretKey, envId }) + const ags = new CloudService(app.context, 'ags', '2025-09-20') + return ags.request(action, param) +} + +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', + }, + } + + 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)) + 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/verify-stateful-e2e.ts b/packages/server/scripts/verify-stateful-e2e.ts new file mode 100644 index 0000000..8daa02a --- /dev/null +++ b/packages/server/scripts/verify-stateful-e2e.ts @@ -0,0 +1,189 @@ +/** + * 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') +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<ReturnType<ReturnType<typeof getSandboxProvider>['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 || 'ovc-verify-env' + const conversationId = `ovc-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.STATEFUL_GATEWAY_URL?.includes('tcloudbasegateway.com') || + process.env.TCB_ENV_ID + ), + ) + emit('config_tcb_api_key', !!process.env.TCB_API_KEY) + + let inst: Awaited<ReturnType<typeof provider.acquire>> + 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 || process.env.TENCENTCLOUD_SECRET_ID || '', + secretKey: process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_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 previewRes = await inst.request(`/preview/${VITE_PORT}/`, { + method: 'GET', + signal: AbortSignal.timeout(15_000), + }) + const body = await previewRes.text() + emit( + 'trw_preview_vite', + previewRes.status < 500 && body.length > 0, + `status=${previewRes.status} bytes=${body.length}`, + ) + } catch (e) { + emit('trw_preview_vite', false, (e as Error).message) + } + + try { + const portsRes = await inst.request('/preview/ports', { signal: AbortSignal.timeout(10_000) }) + const portsJson = (await portsRes.json()) as { ports?: Array<{ port: number }> } + const hasVite = Array.isArray(portsJson.ports) && portsJson.ports.some((p) => p.port === VITE_PORT) + emit('trw_preview_ports', portsRes.ok && hasVite, JSON.stringify(portsJson.ports?.map((p) => p.port) ?? [])) + } catch (e) { + emit('trw_preview_ports', 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 e3633ec..e4345f6 100644 --- a/packages/server/src/agent/cloudbase-agent.service.ts +++ b/packages/server/src/agent/cloudbase-agent.service.ts @@ -1,20 +1,27 @@ 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 { 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, +} 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' @@ -147,6 +154,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' }, ] @@ -187,14 +195,7 @@ export function clearModelsCache(): void { // ─── Sandbox & Prompt Helpers (imported from base-runtime) ─────────────── // 集中在 base-runtime.ts 维护,所有 runtime 共用。 -import { - waitForSandboxHealth, - initSandboxWorkspace, - WRITE_TOOLS, - buildAppendPrompt, - getPublishableKey, - persistDeploymentFromArtifact, -} from './runtime/base-runtime.js' +import { WRITE_TOOLS, buildAppendPrompt, getPublishableKey, persistDeploymentFromArtifact } from './runtime/base-runtime.js' // ─── Types ───────────────────────────────────────────────────────────────── @@ -450,8 +451,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 } @@ -522,16 +540,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, }) @@ -544,22 +561,30 @@ export class CloudbaseAgentService { sandboxConfig = resolveSandboxConfig({ envId: userContext.envId, taskId: conversationId }) } - const { sandboxMode, sandboxSessionId, sandboxCwd: resolvedCwd } = sandboxConfig - const actualCwd = cwd || resolvedCwd + const taskForAcquire = await getDb().tasks.findById(conversationId).catch(() => null) + + const { sandboxCwd: resolvedCwd } = sandboxConfig + // Remote TRW workspace path (semantic only on stateful; tools run in sandbox via MCP). + const workspaceCwd = cwd || resolvedCwd + // CodeBuddy SDK runs on the OVC host — never mkdir/query against /home/user on macOS. + const localCwd = + workspaceCwd === STATEFUL_WORKSPACE_ROOT || workspaceCwd.startsWith('/home/user') + ? path.join(os.tmpdir(), 'ovc-agent', conversationId) + : workspaceCwd console.log( - `[Agent] sandboxConfig: mode=${sandboxMode}, sessionId=${sandboxSessionId}, resolvedCwd=${resolvedCwd}, cwd=${cwd}, actualCwd=${actualCwd}`, + `[Agent] sandboxConfig: workspaceCwd=${workspaceCwd}, localCwd=${localCwd}, cwd=${cwd ?? '(none)'}`, ) - mkdirSync(actualCwd, { recursive: true }) + 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) @@ -575,7 +600,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') @@ -599,7 +624,7 @@ export class CloudbaseAgentService { let historicalMessages: CodeBuddyMessage[] = [] let lastRecordId: string | null = null let hasHistory = false - let sandboxMcpClient: Awaited<ReturnType<typeof createSandboxMcpClient>> | null = null + let sandboxMcpClient: McpClientBundle | null = null // askAnswers / toolConfirmation 场景标记为 resume const isResumeFromInterrupt = (askAnswers && Object.keys(askAnswers).length > 0) || !!toolConfirmation @@ -699,7 +724,7 @@ export class CloudbaseAgentService { let preSavedUserRecordId: string | null = null // DEBUG: ACP SSE event log path (shared with message loop debug dir) - const debugAcpLogDir = path.resolve(actualCwd, 'debug-jsonl') + const debugAcpLogDir = path.resolve(localCwd, 'debug-jsonl') mkdirSync(debugAcpLogDir, { recursive: true }) const debugAcpLogPath = path.join(debugAcpLogDir, `${conversationId}_acp_${Date.now()}.jsonl`) @@ -741,12 +766,13 @@ export class CloudbaseAgentService { } } - // ── 获取 SCF 沙箱 ──────────────────────────────────────────────── + // ── 获取 stateful 沙箱 ─────────────────────────────────────────── let sandboxInstance: SandboxInstance | null = null - let toolOverrideConfig: { url: string; headers: Record<string, string> } | null = null + let toolOverrideConfig: ToolOverrideConfig | null = null let detectedSandboxCwd: string | undefined - const sandboxEnabled = process.env.TCB_ENV_ID && 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 刷屏 @@ -771,25 +797,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=<jwe>, - // 这样 server 侧 authMiddleware 直接走 session 认证。 + let hosting: ToolOverrideConfig['hosting'] | undefined try { const user = await getDb().users.findById(userContext.userId) if (user) { @@ -804,65 +823,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<ReturnType<typeof provider.prepare>> | 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 资源 - console.log('[cloudbase-agent] createSandboxMcpClient', { - userId: userContext.userId, - envId: userContext.envId, - userCredentialsSecretId: userCredentials?.secretId?.slice(0, 8), - hasUserSessionToken: !!userCredentials?.sessionToken, - }) - 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 }) @@ -872,18 +902,18 @@ 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) { @@ -897,7 +927,7 @@ export class CloudbaseAgentService { } // ── Coding mode: mark preview ready ───────────────────────────────────── - // 沙箱 /api/session/init 已内置完整的项目初始化流程: + // TRW POST /api/workspace/init handles workspace bootstrap in the stateful provider. // - seedCodingTemplate: 从内置模板复制(零延迟) // - ensureViteDev: 自动启动 vite dev server + crash 重启 // - node_modules 恢复: tar.gz 缓存 / npm install @@ -1067,7 +1097,7 @@ export class CloudbaseAgentService { conversationId, userContext.envId, userContext.userId, - actualCwd, + workspaceCwd, ) historicalMessages = restored.messages lastRecordId = restored.lastRecordId @@ -1217,15 +1247,27 @@ 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, + ) + : buildAppendPrompt( + workspaceCwd, + conversationId, + userContext.envId, + sandboxConfig.sandboxMode, + false, + ), }, mcpServers, abortController, @@ -1484,7 +1526,7 @@ export class CloudbaseAgentService { console.log('[Agent] starting for-await loop...') // DEBUG: log all messages from messageLoop to a file - const debugMsgLogDir = path.resolve(actualCwd, 'debug-jsonl') + const debugMsgLogDir = path.resolve(localCwd, 'debug-jsonl') mkdirSync(debugMsgLogDir, { recursive: true }) const debugMsgLogPath = path.join(debugMsgLogDir, `${conversationId}_messageloop_${Date.now()}.jsonl`) @@ -1833,7 +1875,7 @@ export class CloudbaseAgentService { userContext.userId, historicalMessages, lastRecordId, - actualCwd, + workspaceCwd, assistantMessageId, isResumeFromInterrupt, preSavedUserRecordId, diff --git a/packages/server/src/agent/runtime/base-runtime.ts b/packages/server/src/agent/runtime/base-runtime.ts index 7a1dab0..eb66a8a 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 获取 * @@ -18,14 +18,21 @@ import type { AgentCallback, AgentCallbackMessage, AgentOptions } from '@coder/shared' 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 { 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 +85,41 @@ export async function waitForSandboxHealth( } /** - * 初始化沙箱工作空间:POST /api/session/init 注入凭证和环境变量 - * 然后 poll /api/scope/info 获取工作目录 + * @deprecated Use SandboxProvider.prepare() (TRW POST /api/workspace/init). + * Kept for exports/tests; stateful path returns TRW 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 ───────────────────────────────────────────────────────── @@ -287,7 +262,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 +271,8 @@ export interface RuntimeContext { /** sandbox session id */ sandboxSessionId: string - /** MCP client (sandbox-mcp-proxy),用于 CloudBase 工具调用 */ - mcpClient: Awaited<ReturnType<typeof createSandboxMcpClient>> | null + /** MCP client (stateful in-process server) for CloudBase tools */ + mcpClient: McpClientBundle | null /** 构建好的 system prompt(含沙箱上下文 + coding mode) */ systemPrompt: string @@ -319,7 +294,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,13 +312,13 @@ export abstract class BaseAgentRuntime implements IAgentRuntime { sandboxMode: 'shared' | 'isolated' sandboxSessionId: string toolOverrideConfig: { url: string; headers: Record<string, string> } | null - mcpClient: Awaited<ReturnType<typeof createSandboxMcpClient>> | 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.SCF_SANDBOX_IMAGE_URI) + const sandboxEnabled = !!(process.env.TCB_ENV_ID && process.env.TCB_API_KEY) if (!sandboxEnabled || !envId) { return { sandbox: null, @@ -358,22 +333,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, }) @@ -381,37 +353,32 @@ export abstract class BaseAgentRuntime implements IAgentRuntime { // Non-critical } - const { sandboxMode, sandboxSessionId } = sandboxConfig - - // Progress bridge const progressBridge: SandboxProgressCallback = ({ phase }) => { if (callback) { callback({ type: 'agent_phase', phase: 'preparing', phaseToolName: `sandbox:${phase}` }) } } + const provider = getSandboxProvider() let sandboxInstance: SandboxInstance | null = null - let toolOverrideConfig: { url: string; headers: Record<string, string> } | null = null - let mcpClient: Awaited<ReturnType<typeof createSandboxMcpClient>> | 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) { @@ -426,56 +393,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<ReturnType<typeof provider.prepare>> | 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) }) @@ -489,13 +459,15 @@ 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) { @@ -511,8 +483,8 @@ export abstract class BaseAgentRuntime implements IAgentRuntime { return { sandbox: sandboxInstance, sandboxCwd: detectedCwd, - sandboxMode, - sandboxSessionId, + sandboxMode: sandboxConfig.sandboxMode, + sandboxSessionId: envId, toolOverrideConfig, mcpClient, sessionJwe: capturedSessionJwe, diff --git a/packages/server/src/agent/runtime/opencode-acp-runtime.ts b/packages/server/src/agent/runtime/opencode-acp-runtime.ts index 0f203f7..378e629 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=<projectRoot>/.opencode (隔离用户全局配置) * SANDBOX_MODE=1 @@ -44,7 +44,7 @@ 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 os from 'node:os' import path from 'node:path' @@ -544,7 +544,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, }), }) diff --git a/packages/server/src/db/cloudbase/repositories.ts b/packages/server/src/db/cloudbase/repositories.ts index 0ba037d..abf9482 100644 --- a/packages/server/src/db/cloudbase/repositories.ts +++ b/packages/server/src/db/cloudbase/repositories.ts @@ -747,9 +747,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 27df08f..dafbabb 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 7b5e5de..bc29426 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<Key, 'createdAt' | 'updatedAt'> & { updatedAt?: number } -export type NewUserResource = Omit<UserResource, 'createdAt' | 'updatedAt' | 'scope' | 'taskId'> & { - 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<UserResource, 'createdAt' | 'updatedAt' | 'scope' | UserResourceNullableFields> & + Partial<Pick<UserResource, UserResourceNullableFields>> & { + scope?: string // defaults to 'user' + taskId?: string | null + createdAt?: number + updatedAt?: number + } export type NewSetting = Omit<Setting, 'createdAt' | 'updatedAt'> & { createdAt?: number diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 72c3b1a..48b7a38 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`) @@ -172,6 +174,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..fcfed02 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,29 @@ 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 +90,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 +141,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 +155,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 +174,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/cloudbase-mcp.ts b/packages/server/src/lib/cloudbase-mcp.ts index 5627d90..a0ea55d 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<DiscoveredTool[]> { @@ -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 写入。 + * 调 TRW PUT /api/workspace/env 写入。 */ export function createInjectCredentials(opts: CreateInjectCredentialsOptions): InjectCredentialsFn { const { userId, envId, conversationId, sandboxFetch, workspaceFolderPaths, on401 } = opts @@ -253,11 +253,10 @@ export function createInjectCredentials(opts: CreateInjectCredentialsOptions): I secretIdPrefix: creds.secretId.slice(0, 8), }) - 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/sandbox-config.ts b/packages/server/src/lib/sandbox-config.ts index ffe3ba9..b3c7e99 100644 --- a/packages/server/src/lib/sandbox-config.ts +++ b/packages/server/src/lib/sandbox-config.ts @@ -1,52 +1,101 @@ /** - * 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 — TRW workspace root + instance isolation mode (shared | isolated). */ +import { getDb } from '../db/index.js' +import { getProvisionMode, type ProvisionMode } from './provision-config.js' + +/** TRW 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 } -/** - * 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}) - */ -export function resolveSandboxConfig(params: ResolveParams): SandboxConfig { - const { envId, taskId } = params +export type SandboxInstanceModeSource = 'db' | 'env' | 'default' - const sandboxMode: 'shared' | 'isolated' = - (params.sandboxMode as 'shared' | 'isolated') || - (process.env.WORKSPACE_ISOLATION === 'isolated' ? 'isolated' : 'shared') +const VALID_MODES: SandboxInstanceMode[] = ['shared', 'isolated'] +const BUILTIN_DEFAULT: SandboxInstanceMode = 'shared' - const sandboxSessionId: string = params.sandboxSessionId || (sandboxMode === 'shared' ? envId : taskId) +export function normalizeSandboxMode(mode: string | null | undefined): SandboxInstanceMode { + if (mode === 'isolated' || mode === 'shared') return mode + return BUILTIN_DEFAULT +} - const sandboxCwd: string = - params.sandboxCwd || (sandboxMode === 'shared' ? `/tmp/workspace/${envId}/${taskId}` : `/tmp/workspace/${taskId}`) +/** 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/') +} - return { sandboxMode, sandboxSessionId, sandboxCwd } +export function normalizeSandboxCwd(cwd: string | null | undefined): string { + if (!cwd || isLegacyScfSandboxCwd(cwd)) return STATEFUL_WORKSPACE_ROOT + return cwd +} + +export function resolveSandboxConfig(params: ResolveParams): SandboxConfig { + const sandboxCwd = normalizeSandboxCwd(params.sandboxCwd) + const sandboxMode = normalizeSandboxMode(params.sandboxMode) + return { sandboxMode, sandboxCwd } } /** - * 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. + * Default instance mode for new tasks (before per-task override). + * Priority: DB `sandbox_instance_mode` → env `SANDBOX_INSTANCE_MODE` → provision-aware builtin. */ +export async function resolveSandboxInstanceMode(): Promise<{ + value: SandboxInstanceMode + source: SandboxInstanceModeSource + envDefault: SandboxInstanceMode +}> { + const envDefault = normalizeSandboxMode(process.env.SANDBOX_INSTANCE_MODE || BUILTIN_DEFAULT) + + try { + const setting = await getDb().settings.findSystemSetting('sandbox_instance_mode') + if (setting?.value) { + return { value: normalizeSandboxMode(setting.value), source: 'db', envDefault } + } + } catch { + // DB unavailable + } + + if (process.env.SANDBOX_INSTANCE_MODE) { + return { value: envDefault, source: 'env', envDefault } + } + + 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<SandboxInstanceMode> { + if (bodyMode && isValidSandboxInstanceMode(bodyMode)) return bodyMode + return (await resolveSandboxInstanceMode()).value +} + export async function backfillSandboxConfig( taskId: string, existing: { @@ -57,21 +106,29 @@ export async function backfillSandboxConfig( envId: string, db: { tasks: { update: (id: string, data: Record<string, unknown>) => Promise<unknown> } }, ): Promise<boolean> { - 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/routes/acp.ts b/packages/server/src/routes/acp.ts index 1a2b3f7..48e4f73 100644 --- a/packages/server/src/routes/acp.ts +++ b/packages/server/src/routes/acp.ts @@ -846,7 +846,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 9a6d128..f9a06ec 100644 --- a/packages/server/src/routes/admin.ts +++ b/packages/server/src/routes/admin.ts @@ -4,6 +4,10 @@ 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 +963,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 +973,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 +1000,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 1cc50a8..5923f3f 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -173,7 +173,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 packages/server/.env' }, 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 453766a..e901dd3 100644 --- a/packages/server/src/routes/tasks.ts +++ b/packages/server/src/routes/tasks.ts @@ -1,16 +1,36 @@ -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 { + STATEFUL_WORKSPACE_ROOT, + resolveSandboxConfig, + resolveSandboxModeForNewTask, + backfillSandboxConfig, + isValidSandboxInstanceMode, +} from '../lib/sandbox-config' +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, + getTaskSandbox, + runCommandInSandbox, + downloadFileFromSandbox, + readFileFromSandbox, + writeFileToSandbox, + detectPackageManager, + type SandboxInstance, +} from '../sandbox/index.js' +import { isViteReadyResult, waitForSandboxViteReady } from '../sandbox/wait-vite-ready.js' import { destroyProvisionedResources, provisionUserResources, @@ -63,111 +83,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<CommandResult> { - 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<SandboxInstance | null> { - 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<PackageManager> { - 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<boolean> { - 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 @@ -317,11 +233,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) @@ -365,7 +288,11 @@ 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 provision failed:', (err as Error).message) @@ -383,7 +310,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 切换场景) @@ -437,7 +368,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) @@ -466,8 +401,7 @@ tasksRouter.post('/', async (c) => { error: null, branchName: null, sandboxId: null, - sandboxSessionId: sandboxConfig?.sandboxSessionId ?? null, - sandboxCwd: sandboxConfig?.sandboxCwd ?? null, + sandboxCwd: sandboxConfig?.sandboxCwd ?? null, sandboxMode: sandboxConfig?.sandboxMode ?? null, agentSessionId: null, sandboxUrl: null, @@ -524,18 +458,52 @@ 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<string> { + const out = new Set<string>() + 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<string, unknown> +} + +type DeleteTaskSuccess = { + ok: true + warning?: string + provisionFailed?: Awaited<ReturnType<typeof destroyProvisionedResources>>['failed'] +} + +async function deleteTaskForUser( + existing: Task, + envId: string, +): Promise<DeleteTaskSuccess | { ok: false; failure: DeleteTaskFailure }> { + 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') { console.log('[task-delete] destroying task-scoped resources', { @@ -553,66 +521,135 @@ tasksRouter.delete('/:taskId', requireUserEnv, async (c) => { envId: taskResource.envId, cosTagValue: taskResource.cosTagValue, }) - } catch (e: any) { - console.warn('[task-delete] destroyProvisionedResources 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] destroyProvisionedResources 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) - console.log('[task-delete] user_resources row removed', { resourceId: 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 () => { + void (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) { + const sandbox = await getTaskSandbox(existing, envId).catch(() => null) + if (sandbox) { + if (existing.sandboxMode === 'isolated') { + const provider = getSandboxProvider() + if (provider.destroy) { + await provider.destroy(sandbox) + } + } else { await deleteConversationViaSandbox(sandbox, envId, taskId, existing.sandboxCwd || undefined) } } - } catch (e) { + } catch { console.log('clean conversation workspace error') } })() - return c.json({ message: 'Task deleted' }) + if (provisionFailed?.length) { + return { + ok: true, + warning: '部分云资源清理失败,任务已从列表移除,后台可重试清理', + provisionFailed, + } + } + return { ok: true } +} + +// Bulk delete by status (sidebar: completed / failed / stopped) +tasksRouter.delete('/', requireUserEnv, async (c) => { + const session = c.get('session')! + const { envId } = c.get('userEnv')! + const action = c.req.query('action') ?? '' + const statusSet = resolveBulkDeleteStatuses(action) + + if (statusSet.size === 0) { + return c.json({ error: 'Invalid or empty action query (e.g. ?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)) + + let deleted = 0 + const failures: Array<{ taskId: string; error: string; detail?: string }> = [] + + for (const task of toDelete) { + const result = await deleteTaskForUser(task, envId) + if (result.ok) { + deleted += 1 + 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, + }) + } + } + + 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', + ...(result.warning ? { warning: result.warning, failed: result.provisionFailed } : {}), + }) }) // Get task messages @@ -795,7 +832,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, @@ -805,7 +842,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, @@ -820,13 +857,13 @@ tasksRouter.get('/:taskId/files', requireUserEnv, async (c) => { .trim() .split('\n') .filter((line) => line.trim()) - const checkRemoteResult = await runCommandInScfSandbox( + 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<string, { additions: number; deletions: number }> = {} if (numstatResult.success) { const numstatOutput = numstatResult.output || '' @@ -857,7 +894,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 } } @@ -877,7 +914,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, @@ -886,7 +923,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/*'", ) @@ -904,7 +941,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<string, 'added' | 'modified' | 'deleted' | 'renamed'> = {} if (statusResult.success) { const statusOutput = statusResult.output || '' @@ -1071,7 +1108,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 @@ -1079,7 +1116,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) } @@ -1141,7 +1178,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) @@ -1213,7 +1250,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 }) @@ -1232,7 +1269,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 }) @@ -1253,7 +1290,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 }) @@ -1303,7 +1340,7 @@ tasksRouter.post('/:taskId/save-file', 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) const success = await writeFileToSandbox(sandbox, filename, content) if (!success) return c.json({ error: 'Failed to write file to sandbox' }, 500) @@ -1334,15 +1371,15 @@ tasksRouter.post('/:taskId/create-file', 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 pathParts = filename.split('/') if (pathParts.length > 1) { const dirPath = pathParts.slice(0, -1).join('/') - const mkdirResult = await runCommandInScfSandbox(sandbox, `mkdir -p '${dirPath.replace(/'/g, "'\\''")}'`) + const mkdirResult = await runCommandInSandbox(sandbox, `mkdir -p '${dirPath.replace(/'/g, "'\\''")}'`) if (!mkdirResult.success) return c.json({ success: false, error: 'Failed to create parent directories' }, 500) } - const touchResult = await runCommandInScfSandbox(sandbox, `touch '${filename.replace(/'/g, "'\\''")}'`) + const touchResult = await runCommandInSandbox(sandbox, `touch '${filename.replace(/'/g, "'\\''")}'`) if (!touchResult.success) return c.json({ success: false, error: 'Failed to create file' }, 500) return c.json({ success: true, message: 'File created successfully', filename }) } catch { @@ -1365,9 +1402,9 @@ tasksRouter.post('/:taskId/create-folder', 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 mkdirResult = await runCommandInScfSandbox(sandbox, `mkdir -p '${foldername.replace(/'/g, "'\\''")}'`) + const mkdirResult = await runCommandInSandbox(sandbox, `mkdir -p '${foldername.replace(/'/g, "'\\''")}'`) if (!mkdirResult.success) return c.json({ success: false, error: 'Failed to create folder' }, 500) return c.json({ success: true, message: 'Folder created successfully', foldername }) } catch { @@ -1389,9 +1426,9 @@ tasksRouter.delete('/:taskId/delete-file', 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 rmResult = await runCommandInScfSandbox(sandbox, `rm '${filename.replace(/'/g, "'\\''")}'`) + const rmResult = await runCommandInSandbox(sandbox, `rm '${filename.replace(/'/g, "'\\''")}'`) if (!rmResult.success) return c.json({ success: false, error: 'Failed to delete file' }, 500) return c.json({ success: true, message: 'File deleted successfully', filename }) } catch { @@ -1413,16 +1450,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({ @@ -1453,16 +1490,16 @@ 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( + 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 : '' @@ -1479,7 +1516,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 : '' @@ -1638,20 +1675,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 { @@ -1824,7 +1861,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) { @@ -1844,7 +1881,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 @@ -1890,8 +1927,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())) @@ -1925,10 +1962,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: { @@ -1960,10 +1997,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() @@ -1984,7 +2021,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 @@ -2361,9 +2398,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) { @@ -2373,181 +2410,9 @@ 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 TRW 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) => { - 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) - - 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}.service.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' - } - - return c.json({ - resolvedSessionId, - scopeId: taskId, - summary: { - directUniqueContainers: directIds, - publicUniqueContainers: publicIds, - overlap, - conclusion, - }, - directResults, - publicResults, - }) - } catch (error) { - return c.json({ error: (error as Error).message }, 500) - } -}) - tasksRouter.get('/:taskId/preview-health', requireUserEnv, async (c) => { try { const session = c.get('session')! @@ -2559,36 +2424,22 @@ tasksRouter.get('/:taskId/preview-health', requireUserEnv, async (c) => { 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({ status: 'no_sandbox' }) - // Query scope/info — scope headers are injected automatically by sandbox.request() - const res = await sandbox.request('/api/scope/info', { + const res = await sandbox.request('/preview/ports', { signal: AbortSignal.timeout(10_000), }) 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 + return c.json({ status: 'error', message: `preview/ports returned ${res.status}` }) } - 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 }) + 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({ status: 'error', message: (error as Error).message }) } @@ -2609,9 +2460,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 { @@ -2620,10 +2471,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() }) + 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.info('Sandbox started successfully') - return c.json({ success: true, message: 'Sandbox started successfully', sandboxId: sandbox.functionName }) + 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) @@ -2633,16 +2493,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) { @@ -2663,7 +2527,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') @@ -2682,11 +2546,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) { @@ -2773,18 +2637,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) @@ -2822,160 +2686,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) + console.warn('[preview-url] workspace/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)证明 `service.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 - } - await new Promise((r) => setTimeout(r, pollInterval)) - } - - if (!port) { - await emit('error', `Dev server 未能在 ${maxWaitMs / 1000}s 内就绪`) + await emit('progress', '正在等待 TRW 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}.service.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 — proxy via OVC. + const gatewayUrl = `/api/tasks/${taskId}/preview/${port}/` await emit('ready', 'Dev server ready', { gatewayUrl, port }) } catch (err) { // 顶层异常兜底:确保前端总能收到 error 事件而非静默关闭 @@ -3008,48 +2829,39 @@ 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) TRW 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}.service.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: TRW /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) } @@ -3090,13 +2902,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`, ) @@ -3122,14 +2934,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), }, }) } @@ -3137,10 +2949,10 @@ 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( + const zipResult = await runCommandInSandbox( sandbox, `mkdir -p .tmp && cd '${quoted}' && zip -r '${tmpZip}' . && echo ok`, 60000, @@ -3149,15 +2961,15 @@ tasksRouter.get('/:taskId/files/download', requireUserEnv, async (c) => { 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 { @@ -3222,4 +3034,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<AppEnv>) { + 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__/preview-proxy.test.ts b/packages/server/src/sandbox/__tests__/preview-proxy.test.ts new file mode 100644 index 0000000..ae0b6f8 --- /dev/null +++ b/packages/server/src/sandbox/__tests__/preview-proxy.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { rewritePreviewPaths } from '../preview-proxy.js' + +describe('rewritePreviewPaths', () => { + it('rewrites vite base asset URLs to task preview proxy', () => { + const html = + '<script type="module" src="/preview/5173/@vite/client"></script>' + + '<link href="/preview/5173/src/main.css">' + const out = rewritePreviewPaths(html, 'task-1', '5173') + expect(out).toContain('/api/tasks/task-1/preview/5173/@vite/client') + expect(out).not.toContain('"/preview/5173/') + }) +}) 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/ensure-stateful-tool.ts b/packages/server/src/sandbox/ensure-stateful-tool.ts new file mode 100644 index 0000000..0634aef --- /dev/null +++ b/packages/server/src/sandbox/ensure-stateful-tool.ts @@ -0,0 +1,171 @@ +/** + * Ensure a CloudBase sandbox Tool (SDT) exists for the given envId. + * Persists tool id in settings (shared) or user_resources (isolated/task scope). + */ + +import { nanoid } from 'nanoid' +import { getDb } from '../db/index.js' +import { getProvisionMode } from '../lib/provision-config.js' + +export const STATEFUL_TOOL_SETTINGS_KEY = 'stateful_tool_id' + +const DEFAULT_TOOL_ROLE_ARN = 'qcs::cam::uin/691612481:roleName/agent-sandbox' + +function sanitizeToolName(envId: string): string { + const slug = envId.replace(/[^a-zA-Z0-9-]/g, '-').slice(0, 48) + return `ovc-${slug || 'default'}` +} + +function resolveSandboxGatewayUrl(envId: string): string { + const explicit = process.env.STATEFUL_GATEWAY_URL || process.env.STATEFUL_SANDBOX_URL || '' + if (explicit) return explicit.replace(/\/$/, '') + 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<string, unknown>, +): Promise<Record<string, unknown>> { + const managerModule = await import('@cloudbase/manager-node') + // @ts-expect-error manager-node ships utils without types + const managerUtilsModule = await import('@cloudbase/manager-node/lib/utils') + const CloudBase = ((managerModule as any).default || managerModule) as any + const CloudService = ((managerUtilsModule as any).CloudService || + (managerUtilsModule as any).default?.CloudService) as any + + const secretId = + process.env.TCB_SECRET_ID || process.env.TENCENTCLOUD_SECRET_ID || process.env.TENCENT_SECRET_ID || '' + const secretKey = + process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_SECRET_KEY || process.env.TENCENT_SECRET_KEY || '' + const token = process.env.TCB_TOKEN || process.env.TENCENTCLOUD_SESSIONTOKEN || '' + const managerEnvId = process.env.TCB_ENV_ID || '' + + if (!secretId || !secretKey || !managerEnvId) { + throw new Error('TCB_ENV_ID and TCB_SECRET_ID/KEY are required to manage sandbox tools') + } + + const app = new CloudBase({ secretId, secretKey, token, envId: managerEnvId }) + const agsService = new CloudService(app.context, 'ags', '2025-09-20') + return (await agsService.request(action, param)) as Record<string, unknown> +} + +async function createSandboxTool(envId: string): Promise<string> { + const image = process.env.STATEFUL_SANDBOX_IMAGE || '' + if (!image) { + throw new Error('Missing STATEFUL_SANDBOX_IMAGE (vibecoding preset image URI for CreateSandboxTool)') + } + + const roleArn = process.env.STATEFUL_TOOL_ROLE_ARN || DEFAULT_TOOL_ROLE_ARN + const toolName = sanitizeToolName(envId) + + const data = { + ToolName: toolName, + ToolType: 'custom', + RoleArn: roleArn, + CustomConfiguration: { + Image: image, + ImageRegistryType: process.env.STATEFUL_IMAGE_REGISTRY || 'personal', + Command: JSON.parse(process.env.STATEFUL_TOOL_COMMAND || '["/init"]'), + Resources: { + CPU: process.env.STATEFUL_TOOL_CPU || '2', + Memory: process.env.STATEFUL_TOOL_MEMORY || '2Gi', + }, + 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: '30m', + Description: `OpenVibeCoding stateful sandbox for env ${envId}`, + } + + const resp = await callAgsManagerApi('CreateSandboxTool', data) + const toolId = + (resp?.ToolId as string) || + ((resp?.data as Record<string, unknown> | 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<string | null> { + 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 +} + +async function persistToolId(envId: string, toolId: string, userId?: string, taskId?: string): Promise<void> { + 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: DB → env override → CreateTool. + */ +export async function ensureStatefulTool( + envId: string, + opts?: { userId?: string; taskId?: string }, +): Promise<string> { + const override = process.env.STATEFUL_TOOL_ID || process.env.STATEFUL_SANDBOX_TOOL_ID || '' + if (override) return override + + const existing = await readStoredToolId(envId, opts?.userId, opts?.taskId) + if (existing) return existing + + const toolId = await createSandboxTool(envId) + await persistToolId(envId, toolId, opts?.userId, opts?.taskId) + return toolId +} + +export function resolveStatefulGatewayUrl(envId: string): string { + return resolveSandboxGatewayUrl(envId) +} + +export async function deleteStatefulToolForEnv(envId: string, toolId: string): Promise<void> { + 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..f24ae50 100644 --- a/packages/server/src/sandbox/git-archive.ts +++ b/packages/server/src/sandbox/git-archive.ts @@ -6,7 +6,8 @@ * - 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' // ─── Types ──────────────────────────────────────────────────────── @@ -66,7 +67,7 @@ export function isGitArchiveConfigured(): boolean { /** * 将沙箱中的变更推送到 Git 归档仓库 * - * 通过沙箱的 /api/tools/git_push 端点执行 git 操作 + * 通过 TRW POST /api/extend/git_push 端点执行 git 操作 * * @param sandbox 沙箱实例 * @param conversationId 会话 ID(用作分支名) @@ -89,7 +90,7 @@ export async function archiveToGit( 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 }), @@ -182,7 +183,17 @@ export async function deleteConversationViaSandbox( conversationId: string, sandboxCwd?: string, ): Promise<void> { - 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..6f25768 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,11 @@ 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..af08229 --- /dev/null +++ b/packages/server/src/sandbox/preview-proxy.ts @@ -0,0 +1,142 @@ +/** + * Proxy browser preview requests to TRW /preview/{port}/ via stateful gateway auth headers. + */ + +import type { Context } from 'hono' +import type { SandboxInstance } from './provider/types.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 TRW vite base paths so subresources load through the OVC proxy 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) +} + +function shouldRewriteBody(contentType: string | null): boolean { + 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<Response> { + const authHeaders = await sandbox.getAuthHeaders() + const upstreamPath = buildUpstreamPreviewPath(port, 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 (shouldRewriteBody(contentType)) { + 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..e561a6a --- /dev/null +++ b/packages/server/src/sandbox/preview-ws-proxy.ts @@ -0,0 +1,102 @@ +/** + * WebSocket upgrade proxy: browser → OVC → TRW /preview/{port}/ (vite HMR). + */ + +import type { IncomingMessage, Server } from 'node:http' +import type { Duplex } from 'node:stream' +import http from 'node:http' +import https from 'node:https' +import { URL } from 'node:url' +import { resolveSandboxForTaskWs } from './ws-auth.js' + +const PREVIEW_WS_RE = /^\/api\/tasks\/([^/]+)\/preview\/(\d+)(\/.*)?$/ + +function buildUpstreamPath(port: string, subpath: string | undefined): string { + const suffix = subpath && subpath !== '/' ? (subpath.startsWith('/') ? subpath : `/${subpath}`) : '/' + return `/preview/${port}${suffix}` +} + +function pipeSockets(a: Duplex, b: Duplex): void { + a.pipe(b) + b.pipe(a) + const onClose = () => { + a.destroy() + b.destroy() + } + a.on('close', onClose) + b.on('close', onClose) + a.on('error', onClose) + b.on('error', onClose) +} + +export function attachPreviewWebSocketProxy(server: Server): void { + server.on('upgrade', (req, clientSocket, _head) => { + const url = req.url ?? '/' + const match = PREVIEW_WS_RE.exec(url.split('?')[0] ?? url) + if (!match) return + + const taskId = match[1] + const port = match[2] + const subpath = match[3] ?? '/' + + void (async () => { + const sandbox = await resolveSandboxForTaskWs(req, taskId) + if (!sandbox) { + if (clientSocket.writable) clientSocket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') + clientSocket.destroy() + return + } + + const upstreamPath = buildUpstreamPath(port, subpath) + const base = new URL(sandbox.baseUrl) + const query = url.includes('?') ? url.slice(url.indexOf('?')) : '' + const targetPath = `${upstreamPath}${query}` + + const authHeaders = await sandbox.getAuthHeaders() + const forwardHeaders: Record<string, string | string[] | undefined> = { ...req.headers } + delete forwardHeaders.host + for (const [k, v] of Object.entries(authHeaders)) { + forwardHeaders[k] = v + } + + const requestFn = base.protocol === 'https:' ? https.request : http.request + const proxyReq = requestFn({ + hostname: base.hostname, + port: base.port || (base.protocol === 'https:' ? 443 : 80), + path: targetPath, + method: req.method, + headers: forwardHeaders, + }) + + proxyReq.on('upgrade', (res, upstreamSocket, upgradeHead) => { + const statusLine = `HTTP/1.1 ${res.statusCode ?? 101} ${res.statusMessage ?? 'Switching Protocols'}` + const headerLines = Object.entries(res.headers) + .flatMap(([key, values]) => + (Array.isArray(values) ? values : [values]).map((v) => `${key}: ${v}`), + ) + .join('\r\n') + clientSocket.write(`${statusLine}\r\n${headerLines}\r\n\r\n`) + if (upgradeHead.length > 0) upstreamSocket.write(upgradeHead) + pipeSockets(clientSocket, upstreamSocket) + }) + + proxyReq.on('error', (err) => { + console.warn('[preview-ws-proxy] upstream error:', (err as Error).message) + if (clientSocket.writable) clientSocket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n') + clientSocket.destroy() + }) + + proxyReq.on('response', (res) => { + if (res.statusCode && res.statusCode >= 400) { + clientSocket.write(`HTTP/1.1 ${res.statusCode} ${res.statusMessage ?? 'Error'}\r\n\r\n`) + clientSocket.destroy() + } + }) + + proxyReq.end() + })().catch((err) => { + console.warn('[preview-ws-proxy] handler error:', (err as Error).message) + clientSocket.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..73e1233 --- /dev/null +++ b/packages/server/src/sandbox/provider/stateful-provider.ts @@ -0,0 +1,550 @@ +/** + * Stateful sandbox provider (CloudBase AGS control plane + TRW data plane). + * + * TRW 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 (manager-node CreateSandboxTool). + * - Instance = runtime container. Provider enforces single-instance lifecycle + * per envId: running->reuse, paused->resume, missing->create. + * + * Auth: TCB_API_KEY (long-lived JWT) used as X-Cloudbase-Authorization Bearer. + * 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 { ensureStatefulTool, resolveStatefulGatewayUrl } from '../ensure-stateful-tool.js' +import { createStatefulMcpClient } from '../stateful/stateful-mcp-client.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 + sandboxBaseUrl: string + toolId: string + preCreatedSandboxId: string + managerSecretId: string + managerSecretKey: string + managerToken: string + managerEnvId: string +} + +function readStatefulRuntimeConfig(envId: string, toolId: string): StatefulRuntimeConfig { + const tcbApiKey = process.env.TCB_API_KEY || '' + const sandboxBaseUrl = resolveStatefulGatewayUrl(envId) + const preCreatedSandboxId = process.env.STATEFUL_SANDBOX_ID || '' + const managerSecretId = + process.env.TCB_SECRET_ID || process.env.TENCENTCLOUD_SECRET_ID || process.env.TENCENT_SECRET_ID || '' + const managerSecretKey = + process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_SECRET_KEY || process.env.TENCENT_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, + sandboxBaseUrl, + toolId, + preCreatedSandboxId, + managerSecretId, + managerSecretKey, + managerToken, + managerEnvId, + } +} + +// ─── Auth headers builder ───────────────────────────────────────────────── + +function buildDataPlaneHeaders(opts: { tcbApiKey: string; sandboxId: string }): Record<string, string> { + return { + 'X-Cloudbase-Authorization': `Bearer ${opts.tcbApiKey}`, + 'E2b-Sandbox-Id': opts.sandboxId, + 'E2b-Sandbox-Port': '9000', + } +} + +// ─── Instance meta bag ──────────────────────────────────────────────────── + +interface StatefulMetaBag { + envId: string + conversationId: string + toolId: string + tcbApiKey: 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 + tcbApiKey: string + sandboxMode: SandboxInstanceMode + cacheKey: string +}): SandboxInstance { + const { sandboxId, toolId, baseUrl, envId, conversationId, tcbApiKey, sandboxMode, cacheKey } = args + const meta: StatefulMetaBag = { envId, conversationId, toolId, tcbApiKey, sandboxMode, cacheKey } + const authHeaders = buildDataPlaneHeaders({ tcbApiKey, sandboxId }) + return { + backend: 'stateful', + id: sandboxId, + templateId: toolId, + baseUrl, + meta: meta as unknown as Record<string, unknown>, + mcpConfig: { + type: 'http', + url: `${baseUrl}/mcp`, + headers: authHeaders, + }, + async getAuthHeaders() { + return { ...authHeaders } + }, + async request(p, opts) { + return fetch(`${baseUrl}${p}`, { + ...opts, + headers: { + ...authHeaders, + ...((opts?.headers as Record<string, string> | undefined) ?? {}), + }, + }) + }, + } +} + +// ─── Tool override module path ──────────────────────────────────────────── +// Stateful runtime reuses tool-override.cjs; CLI patch consumes protocol-neutral +// payload (TRW /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<string, unknown>, + cfg: StatefulRuntimeConfig, +): Promise<Record<string, unknown>> { + const managerModule = await import('@cloudbase/manager-node') + // @ts-expect-error manager-node ships utils without types + const managerUtilsModule = await import('@cloudbase/manager-node/lib/utils') + const CloudBase = ((managerModule as any).default || managerModule) as any + const CloudService = ((managerUtilsModule as any).CloudService || + (managerUtilsModule as any).default?.CloudService) as any + const app = new CloudBase({ + secretId: cfg.managerSecretId, + secretKey: cfg.managerSecretKey, + token: cfg.managerToken, + envId: cfg.managerEnvId, + }) + const agsService = new CloudService(app.context, 'ags', '2025-09-20') + return agsService.request(action, param) +} + +async function startStatefulInstance(cfg: StatefulRuntimeConfig, toolId: string): Promise<string> { + const result = (await callAgsManagerApi( + 'StartSandboxInstance', + { + ToolId: toolId, + Timeout: '30m', + AuthMode: 'NONE', + }, + cfg, + )) as Record<string, unknown> + const data = result?.data as Record<string, unknown> | undefined + const instanceObj = result?.Instance as Record<string, unknown> | 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<StatefulInstanceStatus[]> { + 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<string, unknown> | undefined + const rows = (result?.InstanceSet || data?.InstanceSet || []) as Array<Record<string, unknown>> + 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<void> { + await callAgsManagerApi('StopSandboxInstance', { InstanceId: instanceId }, cfg) +} + +async function pauseStatefulInstance(cfg: StatefulRuntimeConfig, instanceId: string): Promise<void> { + await callAgsManagerApi('PauseSandboxInstance', { InstanceId: instanceId }, cfg) +} + +async function resumeStatefulInstance(cfg: StatefulRuntimeConfig, instanceId: string): Promise<void> { + 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, +): 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) { + const sandboxId = await startStatefulInstance(cfg, toolId) + 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') { + await resumeStatefulInstance(cfg, primary.instanceId) + } + 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, +): 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') { + await resumeStatefulInstance(cfg, hit.instanceId) + } + return { sandboxId: hit.instanceId, created: false } + } + } + + const sandboxId = await startStatefulInstance(cfg, toolId) + return { sandboxId, created: true } +} + +// ─── Health check ───────────────────────────────────────────────────────── + +async function checkHealth(baseUrl: string, headers: Record<string, string>): Promise<boolean> { + 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, tcbApiKey: string): Promise<void> { + const headers = buildDataPlaneHeaders({ tcbApiKey, 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<string, SandboxInstance>() + + 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<SandboxInstance> { + 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 }) + 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: 'reuse', message: '复用已有沙箱...\n' }) + return cached + } + this.instanceCache.delete(key) + } + + let sandboxId: string + if (cfg.preCreatedSandboxId) { + onProgress?.({ phase: 'wait_ready', message: '连接已有沙箱实例...\n' }) + sandboxId = cfg.preCreatedSandboxId + } else { + onProgress?.({ phase: 'create', message: '正在启动云端沙箱实例...\n' }) + if (sandboxMode === 'isolated') { + const ensured = await ensureTaskInstance(cfg, cfg.toolId, preferredSandboxId) + sandboxId = ensured.sandboxId + } else { + const ensured = await ensureSingleEnvInstance(cfg, cfg.toolId) + sandboxId = ensured.sandboxId + } + onProgress?.({ phase: 'wait_ready', message: '等待沙箱实例就绪...\n' }) + await waitForReady(cfg.sandboxBaseUrl, sandboxId, cfg.tcbApiKey) + } + + // Final health check (covers pre-created path too). + const headers = buildDataPlaneHeaders({ tcbApiKey: cfg.tcbApiKey, 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, + tcbApiKey: cfg.tcbApiKey, + sandboxMode, + cacheKey: key, + }) + + this.instanceCache.set(key, inst) + onProgress?.({ phase: 'ready', message: '沙箱已就绪\n' }) + return inst + } + + async getExisting(ctx: AcquireContext): Promise<SandboxInstance | null> { + 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<SessionEnv> { + 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, + }, + }), + 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<void> { + // AGS persistence: COS snapshot is auto-managed by TRW (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<McpClientBundle> { + return createStatefulMcpClient(deps) + } + + async getToolOverrideConfig(inst: SandboxInstance, hosting?: ToolOverrideHosting): Promise<ToolOverrideConfig> { + const headers = await inst.getAuthHeaders() + return { + url: inst.baseUrl, + headers, + modulePath: getStatefulToolOverridePath(), + ...(hosting ? { hosting } : {}), + } + } + + async getPreviewBaseUrl(inst: SandboxInstance): Promise<string> { + // AGS routes preview via the same gateway. No separate gateway provisioning. + return `${inst.baseUrl}/preview` + } + + async destroy(inst: SandboxInstance): Promise<void> { + 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<void> { + 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 TRW. + } + + // ── Optional admin helpers (not on SandboxProvider interface, but useful) ── + + async pause(inst: SandboxInstance): Promise<void> { + const meta = inst.meta as unknown as StatefulMetaBag + await pauseStatefulInstance(readStatefulRuntimeConfig(meta.envId, meta.toolId), inst.id) + } + + async resume(inst: SandboxInstance): Promise<void> { + 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<string, unknown> +} + +export interface PrepareContext { + credentials: { + envId: string + secretId: string + secretKey: string + sessionToken?: string + } + workspaceHint?: string + codingMode?: boolean + backendOptions?: StatefulPrepareOptions + meta?: Record<string, unknown> +} + +export interface ReleaseContext { + conversationId: string + prompt?: string + reason: 'completed' | 'cancelled' | 'error' + backendOptions?: StatefulReleaseOptions + meta?: Record<string, unknown> +} + +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<string, unknown> +} + +export interface McpConfig { + type: 'sse' | 'http' + url: string + headers?: Record<string, string | undefined> + credential?: Record<string, string> +} + +export interface SandboxInstance { + readonly backend: SandboxBackend + readonly id: string + readonly templateId: string + readonly baseUrl: string + readonly meta: Record<string, unknown> + readonly mcpConfig?: McpConfig + getAuthHeaders(): Promise<Record<string, string>> + request(path: string, opts?: RequestInit): Promise<Response> +} + +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<string, unknown> + }) => 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<unknown> + close?(): Promise<void> +} + +export interface McpClientBundle { + client: MinimalMcpClient + server: unknown + sdkServer: unknown + close: () => Promise<void> +} + +export interface ToolOverrideHosting { + presignUrl: string + sessionCookie: string + sessionId: string +} + +export interface ToolOverrideConfig { + url: string + headers: Record<string, string> + modulePath: string + hosting?: ToolOverrideHosting +} + +export interface SandboxProvider { + readonly backend: SandboxBackend + acquire(ctx: AcquireContext, onProgress?: SandboxProgressCallback): Promise<SandboxInstance> + prepare(inst: SandboxInstance, ctx: PrepareContext, onProgress?: SandboxProgressCallback): Promise<SessionEnv> + release(inst: SandboxInstance, ctx: ReleaseContext): Promise<void> + createMcpClient(deps: McpDeps): Promise<McpClientBundle> + getToolOverrideConfig(inst: SandboxInstance, hosting?: ToolOverrideHosting): Promise<ToolOverrideConfig> + getPreviewBaseUrl(inst: SandboxInstance): Promise<string> + getExisting?(ctx: AcquireContext): Promise<SandboxInstance | null> + destroy?(inst: SandboxInstance): Promise<void> + deleteConversation?(inst: SandboxInstance, ctx: DeleteConversationContext): Promise<void> +} 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<string, unknown> - }) => 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<typeof createSdkMcpServer> - /** 显式关闭,释放 transport pair */ - close: () => Promise<void> -}> { - 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<any> { - 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<any> { - 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<any[]> { - 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<string, unknown>): Promise<any> { - 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<typeof sdkTool>[] = [] - - // ── 一站式注册:原生工具 + 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 3533875..0000000 --- a/packages/server/src/sandbox/scf-sandbox-manager.ts +++ /dev/null @@ -1,710 +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: SCF_SANDBOX_SESSION_TTL,默认 1800(30 分钟) */ - sessionTTL: number - /** SCF Session 空闲超时(秒)。env: SCF_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<string, string | undefined> - credential?: { - envId: string - secretId: string - secretKey: string - token: string - } - } - - constructor( - private readonly deps: { - sandboxEnvId: string - getAccessToken: () => Promise<string> - }, - 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<string> { - return this.deps.getAccessToken() - } - - static buildAuthHeaders(accessToken: string, scfSessionId: string): Record<string, string> { - 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<string, string> { - const headers: Record<string, string> = {} - if (sandboxMode === 'shared') { - headers['X-Scope-Id'] = scopeId - } - if (isCodingMode) { - headers['X-Scope-Template'] = 'coding' - } - return headers - } - - async getAuthHeaders(): Promise<Record<string, string>> { - 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<string, string> }> { - return { - url: this.baseUrl, - headers: await this.getAuthHeaders(), - } - } - - async request(path: string, options: RequestInit = {}): Promise<Response> { - return fetch(`${this.baseUrl}${path}`, { - ...options, - headers: { - ...(await this.getAuthHeaders()), - ...(options.headers as Record<string, string> | 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.SCF_SANDBOX_SESSION_TTL) || 1800, - sessionIdleTimeout: Number(process.env.SCF_SANDBOX_SESSION_IDLE_TIMEOUT) || 600, - } - - private cachedAccessToken: { token: string; expiry: number } | null = null - - private getEnvConfig() { - 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.SCF_SANDBOX_FUNCTION_PREFIX || 'sandbox', - imageConfig: { - ImageType: process.env.SCF_SANDBOX_IMAGE_TYPE || 'personal', - ImageUri: process.env.SCF_SANDBOX_IMAGE_URI || '', - ContainerImageAccelerate: process.env.SCF_SANDBOX_IMAGE_ACCELERATE === 'true', - ImagePort: parseInt(process.env.SCF_SANDBOX_IMAGE_PORT || '9000', 10), - }, - } - } - - private async getAdminAccessToken(): Promise<string> { - // 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<string, string> = { - '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<SandboxInstance['mcpConfig']> { - 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<SandboxInstance> { - 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<SandboxInstance | null> { - 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<SandboxInstance> { - 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<void> { - 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<void> { - 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<string>() - - async ensurePreviewGateway(sandbox: SandboxInstance): Promise<string> { - 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<void> { - 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<void> { - 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/e2b-native-client.ts b/packages/server/src/sandbox/stateful/e2b-native-client.ts new file mode 100644 index 0000000..f7f79b3 --- /dev/null +++ b/packages/server/src/sandbox/stateful/e2b-native-client.ts @@ -0,0 +1,120 @@ +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<string, string>) { + 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<any> { + 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<string, string> = { + '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<string, string> = { + ...(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<string | null> { + 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<Uint8Array | null> { + 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<boolean> { + 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<NativeCommandResult> { + 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/stateful-mcp-client.ts b/packages/server/src/sandbox/stateful/stateful-mcp-client.ts new file mode 100644 index 0000000..cdcbed3 --- /dev/null +++ b/packages/server/src/sandbox/stateful/stateful-mcp-client.ts @@ -0,0 +1,820 @@ +/** + * Stateful sandbox MCP client + * + * TRW 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 jobs: STATEFUL_MINIPROGRAM_FEATURE. + */ + +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 { adaptDeployJobStatus, adaptMiniprogramDeployStart } from '../trw-deploy-adapter.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' + } +} + +// ─── JSON Schema → Zod ─────────────────────────────────────────── + +function jsonSchemaToZodRawShape(schema: any): Record<string, z.ZodTypeAny> { + if (!schema || schema.type !== 'object' || !schema.properties) return {} + const shape: Record<string, z.ZodTypeAny> = {} + 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<string, z.ZodTypeAny> = {} + 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, unknown>): 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 ────────────────────────────────────────── + +const MINIPROGRAM_FEATURE_ENABLED = (process.env.STATEFUL_MINIPROGRAM_FEATURE || '').toLowerCase() === 'true' + +export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientBundle> { + 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<any> { + 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<any> { + return apiCall('bash', { command, timeout: timeoutMs }, timeoutMs) + } + + // AGS-specific: credentials go to /api/workspace/env (PUT, flat KV body). + async function injectCredentials(): Promise<void> { + 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<any[]> { + const tmpPath = `.mcporter-schema.json` + await bashCall(`mcporter list cloudbase --schema --output json > ${tmpPath} 2>&1`, 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 parsed = JSON.parse(data.result.content) as any + if (!Array.isArray(parsed.tools)) throw new Error('No tools array in schema response') + return parsed.tools + } + + async function mcporterCall(toolName: string, args: Record<string, unknown>): Promise<any> { + 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<string, unknown>) => { + 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 (gated by env: AGS master may not have the endpoint) ── + const miniprogramDegradedResponse = (extra?: Record<string, unknown>) => ({ + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: true, + message: + 'Miniprogram deploy is not enabled on this AGS deployment. ' + + 'Set STATEFUL_MINIPROGRAM_FEATURE=true once /api/jobs/miniprogram-deploy is available.', + ...extra, + }), + }, + ], + isError: true, + }) + + 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<string, unknown>) => { + if (!MINIPROGRAM_FEATURE_ENABLED) return miniprogramDegradedResponse() + try { + let privateKey: string | undefined + const appId = args.appId as string + if (getMpDeployCredentials) { + const creds = await getMpDeployCredentials(appId) + if (creds) privateKey = creds.privateKey + } + if (!privateKey) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: true, + message: `未找到 appId ${appId} 的部署密钥,请先在小程序管理中关联该 appId`, + }), + }, + ], + isError: true, + } + } + const res = await sandbox.request('/api/jobs/miniprogram-deploy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + appid: appId, + privateKey, + action: args.action, + projectPath: args.projectPath, + version: args.version, + description: args.description, + robot: args.robot, + }), + signal: AbortSignal.timeout(120_000), + }) + const rawBody = (await res.json().catch(() => null)) as unknown + if (!res.ok && res.status !== 202) { + const r = (rawBody ?? {}) as Record<string, unknown> + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: true, + status: res.status, + message: (r.error as string | undefined) || (r.message as string | undefined) || `HTTP ${res.status}`, + }), + }, + ], + isError: true, + } + } + const body = adaptMiniprogramDeployStart(res.status, rawBody) + if (body.async) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + async: true, + jobId: body.jobId, + message: '部署仍在进行中,请稍后使用 getDeployJobStatus 工具查询结果', + }), + }, + ], + } + } + if (!body.success) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: true, + message: body.error || (body.result as { errMsg?: string } | undefined)?.errMsg || 'Deploy failed', + result: body.result, + }), + }, + ], + isError: true, + } + } + return { content: [{ type: 'text' as const, text: JSON.stringify(body) }] } + } catch (e: any) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ error: true, message: e.message }) }], + isError: true, + } + } + }, + ) + + server.tool( + 'getDeployJobStatus', + '查询小程序发布/预览任务的状态。', + { jobId: z.string().describe('publishMiniprogram 返回的 jobId') }, + async (args: Record<string, unknown>) => { + if (!MINIPROGRAM_FEATURE_ENABLED) return miniprogramDegradedResponse({ jobId: args.jobId }) + try { + const res = await sandbox.request(`/api/jobs/${encodeURIComponent(args.jobId as string)}`, { + signal: AbortSignal.timeout(30_000), + }) + const rawBody = (await res.json().catch(() => null)) as unknown + const body = res.ok && rawBody ? adaptDeployJobStatus(rawBody) : { error: true, status: res.status } + return { + content: [{ type: 'text' as const, text: JSON.stringify(body) }], + } + } catch (e: any) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ error: true, message: e.message }) }], + isError: 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<string, unknown>) => { + 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, gated) + 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<string, unknown>) => { + if (!MINIPROGRAM_FEATURE_ENABLED) return miniprogramDegradedResponse() + try { + let privateKey: string | undefined + const appId = args.appId as string + if (getMpDeployCredentials) { + const creds = await getMpDeployCredentials(appId) + if (creds) privateKey = creds.privateKey + } + if (!privateKey) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: true, message: `未找到 appId ${appId} 的部署密钥` }), + }, + ], + isError: true, + } + } + const res = await sandbox.request('/api/jobs/miniprogram-deploy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + appid: appId, + privateKey, + action: args.action, + projectPath: args.projectPath, + version: args.version, + description: args.description, + robot: args.robot, + }), + signal: AbortSignal.timeout(120_000), + }) + const rawBody = (await res.json().catch(() => null)) as unknown + if (!res.ok && res.status !== 202) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ error: true, status: res.status }) }], + isError: true, + } + } + const body = adaptMiniprogramDeployStart(res.status, rawBody) + return { content: [{ type: 'text' as const, text: JSON.stringify(body) }] } + } catch (e: any) { + return { content: [{ type: 'text' as const, text: `Error: ${e.message}` }], isError: true } + } + }, + ), + ) + + sdkTools.push( + sdkTool( + 'getDeployJobStatus', + '查询小程序发布/预览任务的状态。', + { jobId: z.string().describe('publishMiniprogram 返回的 jobId') }, + async (args: Record<string, unknown>) => { + if (!MINIPROGRAM_FEATURE_ENABLED) return miniprogramDegradedResponse({ jobId: args.jobId }) + try { + const res = await sandbox.request(`/api/jobs/${encodeURIComponent(args.jobId as string)}`, { + signal: AbortSignal.timeout(30_000), + }) + const rawBody = (await res.json().catch(() => null)) as unknown + const body = res.ok && rawBody ? adaptDeployJobStatus(rawBody) : { error: true, status: res.status } + return { + content: [{ type: 'text' as const, text: JSON.stringify(body) }], + } + } catch (e: any) { + return { content: [{ type: 'text' as const, text: `Error: ${e.message}` }], isError: true } + } + }, + ), + ) + + // ── cronTask (CRUD via OVC local 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<string, unknown>) => { + 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<string, unknown> = {} + 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=${MINIPROGRAM_FEATURE_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..9475fd4 --- /dev/null +++ b/packages/server/src/sandbox/task-sandbox.ts @@ -0,0 +1,179 @@ +/** + * 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<SandboxInstance | null> { + 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 + if (!options?.allowCreate) 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<CommandResult> { + 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<PackageManager> { + 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 TRW POST /api/tools/files_download (production TRW has no /e2b-compatible). */ +export async function downloadFileFromSandbox( + sandbox: SandboxInstance, + filePath: string, +): Promise<Uint8Array | null> { + 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<boolean> { + 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 — TRW 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..23f17fd 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<string, string>, } /** - * 调用 Process/List 获取沙箱中所有存活进程。 + * 确保指定 pid 在 ptyTaskRegistry 中存在(仅内存;TRW 无 e2b Process/List)。 */ -async function listSandboxProcesses( - baseUrl: string, - headers: Record<string, string>, -): Promise<Array<{ pid: number; cmd: string; args: string[] }>> { - 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<string, string>, -): Promise<PtyTask | null> { - 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<PtyTask | null> { + return ptyTaskRegistry.get(taskId) ?? null } /** @@ -710,7 +657,7 @@ export function overrideTools(toolMap: Map<string, any>): 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<string, any>): 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<string, any>): 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<string, any>): void { return cdnUrl } - // ── fallback:上传到沙箱 /e2b-compatible/files ── + // ── fallback:上传到沙箱 via TRW /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<string, any>): 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..226ec86 --- /dev/null +++ b/packages/server/src/sandbox/trw-deploy-adapter.ts @@ -0,0 +1,127 @@ +/** + * TRW miniprogram deploy job adapter. + * + * As of the TRW 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. + */ + +/** TRW 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<string, unknown> + + // 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<TrwJob> + 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<TrwJob> + 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/wait-vite-ready.ts b/packages/server/src/sandbox/wait-vite-ready.ts new file mode 100644 index 0000000..6d74f4d --- /dev/null +++ b/packages/server/src/sandbox/wait-vite-ready.ts @@ -0,0 +1,86 @@ +/** + * Wait for TRW vite-dev-manager (vibecoding) — do not spawn a second dev server. + * TRW 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<boolean> { + try { + // Hits TRW 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<number[]> { + 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 TRW 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<void> + } = {}, +): Promise<ViteReadyResult> { + 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..6818371 --- /dev/null +++ b/packages/server/src/sandbox/ws-auth.ts @@ -0,0 +1,66 @@ +/** + * 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<string | null> { + 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<SandboxInstance | null> { + const rawCookie = parseCookie(req.headers.cookie, SESSION_COOKIE_NAME) + if (!rawCookie) return null + + let session: AppSession | undefined + try { + session = await decryptJWE<AppSession>(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..fe48a76 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<Array<{ name: string; isDirectory: boolean }> | null> { +/** Find SKILL.md paths under a directory via TRW /api/tools/glob. */ +async function sandboxFindSkillMdPaths(sandbox: SandboxConfig, dirPath: string): Promise<string[]> { 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<SkillDefinition[]> { - // 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/web/src/components/chat/browser-controls.tsx b/packages/web/src/components/chat/browser-controls.tsx index ad0c0eb..6238cff 100644 --- a/packages/web/src/components/chat/browser-controls.tsx +++ b/packages/web/src/components/chat/browser-controls.tsx @@ -165,7 +165,14 @@ function HmrIndicator({ status }: { status: HmrStatus }) { */ function extractDisplayPath(url: string): string { try { - const u = new URL(url) + const base = + typeof window !== 'undefined' ? window.location.origin : 'http://localhost' + const u = new URL(url, base) + const proxyMatch = /^\/api\/tasks\/[^/]+\/preview\/\d+/.exec(u.pathname) + if (proxyMatch) { + const rest = u.pathname.slice(proxyMatch[0].length) || '/' + return rest + u.search + u.hash + } // Hash router: `/#/dashboard` → display `#/dashboard` if (u.hash && u.hash.startsWith('#/')) { return u.hash diff --git a/packages/web/src/components/logs-pane.tsx b/packages/web/src/components/logs-pane.tsx index d8e8913..5ba4690 100644 --- a/packages/web/src/components/logs-pane.tsx +++ b/packages/web/src/components/logs-pane.tsx @@ -6,7 +6,7 @@ import { useState, useEffect, useRef } from 'react' import { toast } from 'sonner' import { useTasks } from '@/components/app-layout' import { getLogsPaneHeight, setLogsPaneHeight, getLogsPaneCollapsed, setLogsPaneCollapsed } from '@/lib/utils/cookies' -import { Terminal, TerminalRef } from '@/components/terminal' +import { Terminal } from '@/components/terminal' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' interface LogsPaneProps { @@ -19,7 +19,6 @@ type LogFilterType = 'all' | 'platform' | 'server' export function LogsPane({ task, onHeightChange }: LogsPaneProps) { const [copiedLogs, setCopiedLogs] = useState(false) - const [copiedTerminal, setCopiedTerminal] = useState(false) const [isCollapsed, setIsCollapsedState] = useState(true) const [paneHeight, setPaneHeight] = useState(200) const [isResizing, setIsResizing] = useState(false) @@ -29,7 +28,6 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { const [isClearingLogs, setIsClearingLogs] = useState(false) const [logFilter, setLogFilter] = useState<LogFilterType>('all') const logsContainerRef = useRef<HTMLDivElement>(null) - const terminalRef = useRef<TerminalRef>(null) const prevLogsLengthRef = useRef<number>(0) const hasInitialScrolled = useRef<boolean>(false) const wasAtBottomRef = useRef<boolean>(true) @@ -201,25 +199,6 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { } } - const clearTerminal = () => { - if (terminalRef.current) { - terminalRef.current.clear() - } - } - - const copyTerminalToClipboard = async () => { - if (terminalRef.current) { - try { - const terminalText = terminalRef.current.getTerminalText() - await navigator.clipboard.writeText(terminalText) - setCopiedTerminal(true) - setTimeout(() => setCopiedTerminal(false), 2000) - } catch { - toast.error('Failed to copy terminal to clipboard') - } - } - } - const handleMouseDown = (e: React.MouseEvent) => { e.preventDefault() setIsResizing(true) @@ -323,28 +302,6 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { </Button> </div> )} - {activeTab === 'terminal' && ( - <div className="flex items-center gap-1 mr-3" onClick={(e) => e.stopPropagation()}> - <Button - variant="ghost" - size="sm" - onClick={clearTerminal} - className="h-5 w-5 p-0 hover:bg-accent" - title="Clear terminal" - > - <Trash2 className="h-3 w-3" /> - </Button> - <Button - variant="ghost" - size="sm" - onClick={copyTerminalToClipboard} - className="h-5 w-5 p-0 hover:bg-accent" - title="Copy terminal to clipboard" - > - {copiedTerminal ? <Check className="h-3 w-3 text-green-600" /> : <Copy className="h-3 w-3" />} - </Button> - </div> - )} </div> <div ref={logsContainerRef} @@ -395,10 +352,10 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { </div> <div className={cn('flex-1 overflow-hidden', (isCollapsed || activeTab !== 'terminal') && 'hidden')}> <Terminal - ref={terminalRef} taskId={task.id} isActive={activeTab === 'terminal' && !isCollapsed} isMobile={!isDesktop} + sandboxReady={!!task.sandboxId} /> </div> </div> diff --git a/packages/web/src/components/task-details.tsx b/packages/web/src/components/task-details.tsx index 1884474..3e0be16 100644 --- a/packages/web/src/components/task-details.tsx +++ b/packages/web/src/components/task-details.tsx @@ -121,6 +121,8 @@ interface TaskDetailsProps { task: Task maxSandboxDuration?: number onStreamComplete?: () => void + /** Refetch task row (previewUrl, sandboxId) without waiting for poll/stream end */ + onTaskRefetch?: () => void initialPrompt?: string initialImages?: Array<{ data: string; mimeType: string }> onInitialPromptConsumed?: () => void @@ -204,6 +206,7 @@ export function TaskDetails({ task, maxSandboxDuration = 300, onStreamComplete, + onTaskRefetch, initialPrompt, initialImages, onInitialPromptConsumed, @@ -262,6 +265,17 @@ export function TaskDetails({ const chatStream = useChatStream(task.id, { onStreamComplete: wrappedOnStreamComplete }) + // Sandbox ready in stream → refetch task so previewUrl/sandboxId reach UI immediately + const lastSandboxReadyRefetchRef = useRef(0) + useEffect(() => { + if (!isCodingModeForAutoFix || !onTaskRefetch) return + if (chatStream.agentPhase.toolName !== 'sandbox:ready') return + const ts = chatStream.agentPhase.timestamp + if (ts <= lastSandboxReadyRefetchRef.current) return + lastSandboxReadyRefetchRef.current = ts + onTaskRefetch() + }, [chatStream.agentPhase.toolName, chatStream.agentPhase.timestamp, isCodingModeForAutoFix, onTaskRefetch]) + // Handle initial prompt (once) at this level const initialTriggered = useRef(false) useEffect(() => { @@ -489,15 +503,13 @@ export function TaskDetails({ } }, []) - // coding mode: preview pane 打开时加载 URL(若尚未加载) - // 注意: 必须检查 !previewGatewayError,否则出错后 loading=false+url=null - // 会导致 effect 再次触发 → 无限轮询 - // 注意: 必须检查 task.previewUrl,等 agent 完成 initCodingProject + startDevServer 后才触发 + // coding mode: preview pane 打开且沙箱已分配时拉 preview-url SSE(不依赖 15s 轮询拿到 previewUrl) + const codingPreviewCanLoad = !!(task.sandboxId || task.previewUrl) useEffect(() => { if ( isCodingMode && showPreviewPane && - task.previewUrl && + codingPreviewCanLoad && !previewGatewayUrl && !previewGatewayLoading && !previewGatewayError @@ -507,6 +519,8 @@ export function TaskDetails({ }, [ isCodingMode, showPreviewPane, + codingPreviewCanLoad, + task.sandboxId, task.previewUrl, previewGatewayUrl, previewGatewayLoading, @@ -1787,6 +1801,7 @@ export function TaskDetails({ try { const response = await fetch(`/api/tasks/${task.id}`, { method: 'DELETE', + credentials: 'include', }) if (response.ok) { @@ -2460,7 +2475,12 @@ export function TaskDetails({ {/* 工具栏:BrowserControls + 刷新 + 全屏 */} <div className="flex h-8 shrink-0 items-center gap-1 border-b bg-muted/20 px-2"> <BrowserControls - previewUrl={previewGatewayUrl || 'http://localhost:5173'} + previewUrl={ + previewGatewayUrl || + (codingPreviewCanLoad + ? `/api/tasks/${task.id}/preview/5173/` + : 'http://localhost:5173') + } bridge={previewBridge} onHardRefresh={() => { setPreviewKey((k) => k + 1) @@ -2566,8 +2586,8 @@ export function TaskDetails({ </div> {/* 内容区 */} <div className="relative flex-1 min-h-0"> - {/* 项目未初始化:等 agent 完成 initCodingProject + startDevServer */} - {!task.previewUrl && !previewGatewayLoading && !previewGatewayUrl && !previewGatewayError && ( + {/* 沙箱尚未分配 */} + {!codingPreviewCanLoad && !previewGatewayLoading && !previewGatewayUrl && !previewGatewayError && ( <div className="absolute inset-0 flex items-center justify-center"> <div className="flex flex-col items-center gap-2 text-sm text-muted-foreground text-center"> <Loader2 className="h-5 w-5 animate-spin" /> @@ -2575,6 +2595,18 @@ export function TaskDetails({ </div> </div> )} + {/* 沙箱已有但 preview-url 尚未返回(避免空白 + 地址栏假 /) */} + {codingPreviewCanLoad && + !previewGatewayLoading && + !previewGatewayUrl && + !previewGatewayError && ( + <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-[5]"> + <div className="flex flex-col items-center gap-2 text-sm text-muted-foreground bg-background/80 backdrop-blur rounded-md px-4 py-3 shadow text-center"> + <Loader2 className="h-5 w-5 animate-spin" /> + <span>正在连接预览网关…</span> + </div> + </div> + )} {/* Loading 状态:实时显示后端推送的进度 */} {previewGatewayLoading && ( <> @@ -2641,7 +2673,10 @@ export function TaskDetails({ <div className="absolute inset-0 z-10"> <PreviewPlaceholder /> <div className="absolute inset-0 flex items-center justify-center pointer-events-none"> - <Loader2 className="h-5 w-5 animate-spin text-muted-foreground/60" /> + <div className="flex flex-col items-center gap-2 text-sm text-muted-foreground bg-background/80 backdrop-blur rounded-md px-4 py-3 shadow text-center"> + <Loader2 className="h-5 w-5 animate-spin" /> + <span>正在加载预览页面…</span> + </div> </div> </div> )} diff --git a/packages/web/src/components/task-page-client.tsx b/packages/web/src/components/task-page-client.tsx index 976342c..0abd7a0 100644 --- a/packages/web/src/components/task-page-client.tsx +++ b/packages/web/src/components/task-page-client.tsx @@ -120,6 +120,7 @@ export function TaskPageClient({ task={task} maxSandboxDuration={maxSandboxDuration} onStreamComplete={refetch} + onTaskRefetch={refetch} initialPrompt={initialPrompt} initialImages={initialImages} onInitialPromptConsumed={handleInitialPromptConsumed} diff --git a/packages/web/src/components/task-sidebar.tsx b/packages/web/src/components/task-sidebar.tsx index 849a3fc..a0881d4 100644 --- a/packages/web/src/components/task-sidebar.tsx +++ b/packages/web/src/components/task-sidebar.tsx @@ -1,7 +1,7 @@ import type { Task } from '@coder/shared' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { AlertCircle, Plus, Trash2, GitBranch, Loader2, Search, X, MoreVertical, Smartphone, Clock } from 'lucide-react' +import { AlertCircle, Plus, Trash2, GitBranch, Loader2, Search, X, Smartphone, Clock } from 'lucide-react' import { cn } from '@/lib/utils' import { Link, useLocation } from 'react-router' import { Claude, CodeBuddy, Codex, Copilot, Cursor, Gemini, OpenCode } from '@/components/logos' @@ -17,7 +17,6 @@ import { } from '@/components/ui/alert-dialog' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { useState, useMemo, useEffect, useRef, useCallback } from 'react' import { toast } from 'sonner' import { useTasks } from '@/components/app-layout' @@ -300,27 +299,6 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { fetchSearchResults, ]) - const handleDeleteSingleTask = async (taskId: string, e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - try { - const response = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE', credentials: 'include' }) - if (response.ok) { - toast.success('Task deleted') - refreshTasks() - } else { - const data = await response.json() - // 后端 409 时 failed 数组含每步的 step / message / code / requestId - const detail = Array.isArray(data?.failed) - ? data.failed.map((f: any) => `[${f.step}] ${f.message || f.code || 'failed'}`).join(';') - : data?.detail || '' - toast.error(detail ? `${data.error || '删除失败'}:${detail}` : data.error || 'Failed to delete task') - } - } catch { - toast.error('Failed to delete task') - } - } - const handleDeleteTasks = async () => { if (!deleteCompleted && !deleteFailed && !deleteStopped) { toast.error('Please select at least one task type to delete') @@ -331,20 +309,21 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { try { const actions = [] if (deleteCompleted) actions.push('completed') - if (deleteFailed) actions.push('failed') + if (deleteFailed) actions.push('error') if (deleteStopped) actions.push('stopped') const response = await fetch(`/api/tasks?action=${actions.join(',')}`, { method: 'DELETE', + credentials: 'include', }) if (response.ok) { - const result = await response.json() - toast.success(result.message) + const result = await response.json().catch(() => ({})) + toast.success(result.message || 'Tasks deleted') await refreshTasks() setShowDeleteDialog(false) } else { - const error = await response.json() + const error = await response.json().catch(() => ({})) toast.error(error.error || 'Failed to delete tasks') } } catch (error) { @@ -562,28 +541,6 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { {task.status === 'stopped' && ( <AlertCircle className="h-3 w-3 text-orange-500 flex-shrink-0" /> )} - <DropdownMenu> - <DropdownMenuTrigger - asChild - onClick={(e) => { - e.preventDefault() - e.stopPropagation() - }} - > - <button className="h-5 w-5 p-0 flex items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-muted-foreground/20 transition-opacity"> - <MoreVertical className="h-3 w-3" /> - </button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-36"> - <DropdownMenuItem - className="text-xs text-destructive cursor-pointer" - onClick={(e) => handleDeleteSingleTask(task.id, e)} - > - <Trash2 className="h-3 w-3 mr-1.5" /> - Delete - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> </div> </div> {task.repoUrl && ( diff --git a/packages/web/src/components/terminal.tsx b/packages/web/src/components/terminal.tsx index e6ffc8f..3a87092 100644 --- a/packages/web/src/components/terminal.tsx +++ b/packages/web/src/components/terminal.tsx @@ -1,10 +1,15 @@ -import { useState, useRef, useImperativeHandle, forwardRef } from 'react' +/** + * Web terminal via TRW ttyd — virtual port 7681, proxied as /api/tasks/:id/preview/7681/. + */ + +import { forwardRef, useImperativeHandle } from 'react' interface TerminalProps { taskId: string className?: string isActive?: boolean isMobile?: boolean + sandboxReady?: boolean } export interface TerminalRef { @@ -13,20 +18,36 @@ export interface TerminalRef { } export const Terminal = forwardRef<TerminalRef, TerminalProps>(function Terminal( - { taskId: _taskId, className: _className, isActive: _isActive, isMobile: _isMobile }, + { taskId, className, isActive, sandboxReady = true }, ref, ) { - const [text, setText] = useState('') - const containerRef = useRef<HTMLDivElement>(null) - useImperativeHandle(ref, () => ({ - clear: () => setText(''), - getTerminalText: () => text, + clear: () => {}, + getTerminalText: () => '', })) + if (!sandboxReady) { + return ( + <div + className={`h-full bg-black text-muted-foreground p-2 font-mono text-xs flex items-center justify-center ${className ?? ''}`} + > + 沙箱未就绪,终端暂不可用 + </div> + ) + } + + if (!isActive) { + return <div className={`h-full min-h-0 bg-black ${className ?? ''}`} /> + } + + const src = `/api/tasks/${taskId}/preview/7681/` + return ( - <div ref={containerRef} className="h-full bg-black text-green-400 p-2 font-mono text-xs overflow-y-auto"> - <div className="text-muted-foreground">Terminal (stub) - not yet connected</div> - </div> + <iframe + src={src} + title="Web Terminal" + className={`h-full min-h-0 w-full border-0 bg-black ${className ?? ''}`} + allow="clipboard-read; clipboard-write" + /> ) }) diff --git a/packages/web/src/hooks/use-preview-bridge.ts b/packages/web/src/hooks/use-preview-bridge.ts index 15b6370..415c542 100644 --- a/packages/web/src/hooks/use-preview-bridge.ts +++ b/packages/web/src/hooks/use-preview-bridge.ts @@ -99,7 +99,9 @@ export function usePreviewBridge(options: UsePreviewBridgeOptions): PreviewBridg let iframeOrigin: string | null = null try { - iframeOrigin = new URL(previewUrl).origin + const base = + typeof window !== 'undefined' ? window.location.origin : 'http://localhost' + iframeOrigin = new URL(previewUrl, base).origin } catch { iframeOrigin = null } diff --git a/packages/web/src/hooks/use-task.ts b/packages/web/src/hooks/use-task.ts index 5568490..dd041fd 100644 --- a/packages/web/src/hooks/use-task.ts +++ b/packages/web/src/hooks/use-task.ts @@ -25,10 +25,11 @@ export function useTask(taskId: string) { fetchTask() }, [fetchTask]) - // Only poll when task is still running + // Poll while running so previewUrl/sandboxId reach UI without waiting for stream end useEffect(() => { if (!task || TERMINAL_STATUSES.has(task.status)) return - const interval = setInterval(fetchTask, 15000) + const intervalMs = task.status === 'processing' ? 3000 : 15000 + const interval = setInterval(fetchTask, intervalMs) return () => clearInterval(interval) }, [task?.status, fetchTask]) diff --git a/packages/web/src/pages/LoginPage.tsx b/packages/web/src/pages/LoginPage.tsx index 40517ef..91aa2dd 100644 --- a/packages/web/src/pages/LoginPage.tsx +++ b/packages/web/src/pages/LoginPage.tsx @@ -35,7 +35,8 @@ export function LoginPage() { setSession({ user: data.user, envId: data.envId }) navigate('/') } catch (err) { - setError(err instanceof Error ? err.message : 'Failed') + const message = err instanceof Error ? err.message : 'Failed' + setError(message === 'Registration failed' ? '注册失败,请查看服务端日志或联系管理员' : message) } finally { setIsLoading(false) } diff --git a/packages/web/src/pages/admin/settings-page.tsx b/packages/web/src/pages/admin/settings-page.tsx index 468f0ec..e3dce80 100644 --- a/packages/web/src/pages/admin/settings-page.tsx +++ b/packages/web/src/pages/admin/settings-page.tsx @@ -29,6 +29,21 @@ const PROVISION_MODES = [ }, ] as const +const SANDBOX_INSTANCE_MODES = [ + { + value: 'shared', + label: '共享实例', + description: '同一 CloudBase 环境下的所有任务共用一个 AGS 沙箱实例(共盘 /home/user)', + badge: '默认', + }, + { + value: 'isolated', + label: '按任务隔离', + description: '每个任务独立一个 AGS 沙箱实例;删除任务会停止对应实例', + badge: '隔离', + }, +] as const + type ProvisionSource = 'db' | 'env' | 'default' interface ProvisionMeta { source: ProvisionSource @@ -51,6 +66,10 @@ export function AdminSettingsPage() { const [saving, setSaving] = useState(false) const [resetting, setResetting] = useState(false) const [provisionMode, setProvisionMode] = useState('shared') + const [sandboxInstanceMeta, setSandboxInstanceMeta] = useState<ProvisionMeta | null>(null) + const [sandboxInstanceMode, setSandboxInstanceMode] = useState('shared') + const [savingInstance, setSavingInstance] = useState(false) + const [resettingInstance, setResettingInstance] = useState(false) useEffect(() => { loadSettings() @@ -61,11 +80,16 @@ export function AdminSettingsPage() { setLoading(true) const data = (await api.get('/api/admin/system-settings')) as { settings: Record<string, string> - meta?: { provision_mode?: ProvisionMeta } + meta?: { + provision_mode?: ProvisionMeta + sandbox_instance_mode?: ProvisionMeta + } } setSettings(data.settings) setMeta(data.meta?.provision_mode ?? null) + setSandboxInstanceMeta(data.meta?.sandbox_instance_mode ?? null) setProvisionMode(data.settings['provision_mode'] || 'shared') + setSandboxInstanceMode(data.settings['sandbox_instance_mode'] || 'shared') } catch (e: any) { toast.error('加载设置失败', { description: e.message }) } finally { @@ -99,9 +123,38 @@ export function AdminSettingsPage() { } } + async function handleSaveInstance() { + try { + setSavingInstance(true) + await api.put('/api/admin/system-settings/sandbox_instance_mode', { value: sandboxInstanceMode }) + toast.success('沙箱实例策略已保存') + await loadSettings() + } catch (e: any) { + toast.error('保存失败', { description: e.message }) + } finally { + setSavingInstance(false) + } + } + + async function handleResetInstance() { + try { + setResettingInstance(true) + await api.delete('/api/admin/system-settings/sandbox_instance_mode') + toast.success('已重置为部署默认值') + await loadSettings() + } catch (e: any) { + toast.error('重置失败', { description: e.message }) + } finally { + setResettingInstance(false) + } + } + const isDirty = provisionMode !== (settings['provision_mode'] || 'shared') + const isInstanceDirty = sandboxInstanceMode !== (settings['sandbox_instance_mode'] || 'shared') const fromDb = meta?.source === 'db' const sourceInfo = meta ? SOURCE_LABEL[meta.source] : null + const instanceFromDb = sandboxInstanceMeta?.source === 'db' + const instanceSourceInfo = sandboxInstanceMeta ? SOURCE_LABEL[sandboxInstanceMeta.source] : null if (loading) { return ( @@ -193,6 +246,90 @@ export function AdminSettingsPage() { </div> </div> </Card> + + <Card className="p-6"> + <div className="space-y-4"> + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <h2 className="text-lg font-medium">沙箱实例复用</h2> + {instanceSourceInfo && ( + <Badge variant="outline" className={`text-xs ${instanceSourceInfo.tone}`}> + 来源:{instanceSourceInfo.text} + </Badge> + )} + </div> + <p className="text-sm text-muted-foreground"> + 控制同一 CloudBase 环境下,多个任务是否共用同一个 AGS 运行时实例(与上方「环境隔离」正交) + </p> + <p className="text-xs text-muted-foreground"> + 优先级:管理员设置(DB) > <code className="px-1 rounded bg-muted">SANDBOX_INSTANCE_MODE</code>{' '} + > 默认(provision=task 时为 isolated,否则 shared) + </p> + </div> + + <RadioGroup + value={sandboxInstanceMode} + onValueChange={setSandboxInstanceMode} + className="space-y-3" + > + {SANDBOX_INSTANCE_MODES.map((mode) => ( + <label + key={mode.value} + className={`flex items-start gap-3 rounded-lg border p-4 cursor-pointer transition-colors ${ + sandboxInstanceMode === mode.value + ? 'border-primary bg-primary/5' + : 'border-border hover:border-muted-foreground/30' + }`} + > + <RadioGroupItem value={mode.value} id={`sandbox-${mode.value}`} className="mt-0.5" /> + <div className="flex-1 space-y-1"> + <div className="flex items-center gap-2"> + <Label htmlFor={`sandbox-${mode.value}`} className="text-sm font-medium cursor-pointer"> + {mode.label} + </Label> + <Badge variant="outline" className="text-xs"> + {mode.badge} + </Badge> + </div> + <p className="text-sm text-muted-foreground">{mode.description}</p> + </div> + </label> + ))} + </RadioGroup> + + {isInstanceDirty && ( + <div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-3"> + <AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" /> + <p className="text-sm text-amber-700 dark:text-amber-300"> + 仅对新创建的任务生效;已有任务的 sandboxMode 不变。 + </p> + </div> + )} + + <div className="flex justify-end gap-2"> + {instanceFromDb && sandboxInstanceMeta && ( + <Button + variant="outline" + onClick={handleResetInstance} + disabled={resettingInstance || savingInstance} + size="sm" + title={`回落到环境变量值:${sandboxInstanceMeta.envDefault}`} + > + <RotateCcw className="h-4 w-4 mr-1" /> + {resettingInstance ? '重置中...' : `重置为部署默认(${sandboxInstanceMeta.envDefault})`} + </Button> + )} + <Button + onClick={handleSaveInstance} + disabled={!isInstanceDirty || savingInstance || resettingInstance} + size="sm" + > + <Save className="h-4 w-4 mr-1" /> + {savingInstance ? '保存中...' : '保存设置'} + </Button> + </div> + </div> + </Card> </div> ) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1670ba..d09b6c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -221,6 +221,9 @@ importers: drizzle-orm: specifier: ^0.36.4 version: 0.36.4(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/react@19.2.14)(better-sqlite3@12.8.0)(react@19.2.1) + e2b: + specifier: ^2.19.5 + version: 2.21.0 fflate: specifier: ^0.8.2 version: 0.8.2 @@ -248,6 +251,9 @@ importers: uuid: specifier: ^11.0.0 version: 11.1.0 + ws: + specifier: ^8.18.0 + version: 8.20.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -267,6 +273,9 @@ importers: '@types/uuid': specifier: ^11.0.0 version: 11.0.0 + '@types/ws': + specifier: ^8.5.13 + version: 8.18.1 tsup: specifier: ^8.0.0 version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.7.3)(yaml@2.8.3) @@ -355,6 +364,12 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@xterm/addon-fit': + specifier: ^0.10.0 + version: 0.10.0(@xterm/xterm@5.5.0) + '@xterm/xterm': + specifier: ^5.5.0 + version: 5.5.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -528,6 +543,9 @@ packages: '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bufbuild/protobuf@2.12.0': + resolution: {integrity: sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==} + '@chevrotain/cst-dts-gen@11.1.2': resolution: {integrity: sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==} @@ -656,6 +674,17 @@ packages: '@cloudbase/wx-cloud-client-sdk@1.7.1': resolution: {integrity: sha512-NzLOoYDAeoRh2lRjCtvr6yXu4KPVHqZtPxyv70e+3b2+dZIDE5S5gtU8daE7xS5u4TwPqKB/E2vmgRTYOsCS9w==} + '@connectrpc/connect-web@2.0.0-rc.3': + resolution: {integrity: sha512-w88P8Lsn5CCsA7MFRl2e6oLY4J/5toiNtJns/YJrlyQaWOy3RO8pDgkz+iIkG98RPMhj2thuBvsd3Cn4DKKCkw==} + peerDependencies: + '@bufbuild/protobuf': ^2.2.0 + '@connectrpc/connect': 2.0.0-rc.3 + + '@connectrpc/connect@2.0.0-rc.3': + resolution: {integrity: sha512-ARBt64yEyKbanyRETTjcjJuHr2YXorzQo0etyS5+P6oSeW8xEuzajA9g+zDnMcj1hlX2dQE93foIWQGfpru7gQ==} + peerDependencies: + '@bufbuild/protobuf': ^2.2.0 + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1441,6 +1470,14 @@ packages: cpu: [x64] os: [win32] + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2570,8 +2607,12 @@ packages: resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==} deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed. + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} @@ -2617,6 +2658,14 @@ packages: '@vue/shared@3.5.32': resolution: {integrity: sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==} + '@xterm/addon-fit@0.10.0': + resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/xterm@5.5.0': + resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} + '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} @@ -2929,6 +2978,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -2960,6 +3013,10 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + chunk-data@0.1.0: resolution: {integrity: sha512-zFyPtyC0SZ6Zu79b9sOYtXZcgrsXe0RpePrzRyj52hYVFG1+Rk6rBqjjOEk+GNQwc3PIX+86teQMok970pod1g==} engines: {node: '>=20'} @@ -3031,6 +3088,9 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + compress-commons@2.1.1: resolution: {integrity: sha512-eVw6n7CnEMFzc3duyFVrQEuY1BlHR3rYsSztyG32ibGMW722i3C6IizEGMFmfMU+A+fALvBIwxN3czffTcdA+Q==} engines: {node: '>= 6'} @@ -3400,6 +3460,9 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dockerfile-ast@0.7.1: + resolution: {integrity: sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw==} + dompurify@3.2.7: resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} @@ -3521,6 +3584,10 @@ packages: duplexer2@0.1.4: resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + e2b@2.21.0: + resolution: {integrity: sha512-rXZX5KpGGjOe21ZDlL29hZfkHUb74ociUwrgf6jD3K+xrAeTFpoIU0ItlLDtu8ihWRIp9ld473RgtSbZLqJpig==} + engines: {node: '>=20.18.1'} + ecc-jsbn@0.1.2: resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} @@ -3841,6 +3908,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + forever-agent@0.6.1: resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} @@ -3961,9 +4032,15 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} @@ -4290,6 +4367,10 @@ packages: isstream@0.1.2: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -4616,6 +4697,10 @@ packages: lowlight@3.3.0: resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@11.5.0: + resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -4886,6 +4971,14 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -5012,6 +5105,12 @@ packages: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} + openapi-fetch@0.14.1: + resolution: {integrity: sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==} + + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -5048,6 +5147,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} @@ -5093,6 +5195,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -5155,6 +5261,9 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -5590,6 +5699,10 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -5757,6 +5870,10 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tar@7.5.15: + resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==} + engines: {node: '>=18'} + tencentcloud-sdk-nodejs@4.1.206: resolution: {integrity: sha512-jwVw8yg3LtfhTy8VNmzpbHFpC1njVcvzq8t5g8xOuCB4TK8MJy1DbI8oQCNjaZuPvepfWyYMubfg7tjwrxV/Mg==} engines: {node: '>=10'} @@ -5926,6 +6043,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -6242,6 +6363,10 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@1.10.3: resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} @@ -6437,6 +6562,8 @@ snapshots: '@braintree/sanitize-url@7.1.2': {} + '@bufbuild/protobuf@2.12.0': {} + '@chevrotain/cst-dts-gen@11.1.2': dependencies: '@chevrotain/gast': 11.1.2 @@ -6727,6 +6854,15 @@ snapshots: dependencies: core-js-pure: 3.49.0 + '@connectrpc/connect-web@2.0.0-rc.3(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.12.0))': + dependencies: + '@bufbuild/protobuf': 2.12.0 + '@connectrpc/connect': 2.0.0-rc.3(@bufbuild/protobuf@2.12.0) + + '@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.12.0)': + dependencies: + '@bufbuild/protobuf': 2.12.0 + '@drizzle-team/brocli@0.10.2': {} '@esbuild-kit/core-utils@3.3.2': @@ -7226,6 +7362,12 @@ snapshots: '@img/sharp-win32-x64@0.33.5': optional: true + '@isaacs/cliui@9.0.0': {} + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -8307,7 +8449,7 @@ snapshots: '@types/glob@7.2.0': dependencies: '@types/minimatch': 6.0.0 - '@types/node': 20.19.39 + '@types/node': 22.19.17 '@types/hast@3.0.4': dependencies: @@ -8364,6 +8506,10 @@ snapshots: dependencies: uuid: 13.0.0 + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.17 + '@ungap/structured-clone@1.3.0': {} '@upsetjs/venn.js@2.0.0': @@ -8431,6 +8577,12 @@ snapshots: '@vue/shared@3.5.32': {} + '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/xterm@5.5.0': {} + '@yarnpkg/lockfile@1.1.0': {} accepts@2.0.0: @@ -8782,6 +8934,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -8812,6 +8966,8 @@ snapshots: chownr@1.1.4: {} + chownr@3.0.0: {} + chunk-data@0.1.0: {} ci-info@3.9.0: {} @@ -8866,6 +9022,8 @@ snapshots: commander@8.3.0: {} + compare-versions@6.1.1: {} + compress-commons@2.1.1: dependencies: buffer-crc32: 0.2.13 @@ -9278,6 +9436,11 @@ snapshots: dependencies: path-type: 4.0.0 + dockerfile-ast@0.7.1: + dependencies: + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + dompurify@3.2.7: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -9322,6 +9485,20 @@ snapshots: dependencies: readable-stream: 2.3.8 + e2b@2.21.0: + dependencies: + '@bufbuild/protobuf': 2.12.0 + '@connectrpc/connect': 2.0.0-rc.3(@bufbuild/protobuf@2.12.0) + '@connectrpc/connect-web': 2.0.0-rc.3(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.12.0)) + chalk: 5.6.2 + compare-versions: 6.1.1 + dockerfile-ast: 0.7.1 + glob: 11.1.0 + openapi-fetch: 0.14.1 + platform: 1.3.6 + tar: 7.5.15 + undici: 7.25.0 + ecc-jsbn@0.1.2: dependencies: jsbn: 0.1.1 @@ -9743,6 +9920,11 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + forever-agent@0.6.1: {} form-data@2.3.3: @@ -9870,6 +10052,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -10240,6 +10431,10 @@ snapshots: isstream@0.1.2: {} + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + jiti@2.6.1: {} jose@6.2.2: {} @@ -10517,6 +10712,8 @@ snapshots: devlop: 1.1.0 highlight.js: 11.11.1 + lru-cache@11.5.0: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -11016,6 +11213,12 @@ snapshots: minimist@1.2.8: {} + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + mkdirp-classic@0.5.3: {} mlly@1.8.2: @@ -11122,6 +11325,12 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openapi-fetch@0.14.1: + dependencies: + openapi-typescript-helpers: 0.0.15 + + openapi-typescript-helpers@0.0.15: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -11169,6 +11378,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + package-manager-detector@1.6.0: {} parent-module@1.0.1: @@ -11225,6 +11436,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.0 + minipass: 7.1.3 + path-to-regexp@8.4.2: {} path-type@4.0.0: {} @@ -11267,6 +11483,8 @@ snapshots: dependencies: find-up: 3.0.0 + platform@1.3.6: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -11799,6 +12017,8 @@ snapshots: signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + simple-concat@1.0.1: {} simple-get@4.0.1: @@ -11998,6 +12218,14 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + tar@7.5.15: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + tencentcloud-sdk-nodejs@4.1.206: dependencies: form-data: 3.0.4 @@ -12162,6 +12390,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.25.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -12468,6 +12698,8 @@ snapshots: yallist@3.1.1: {} + yallist@5.0.0: {} + yaml@1.10.3: {} yaml@2.8.3: {} From 33d288aa8903a55579ae5e0a6afedef5854d2990 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Thu, 21 May 2026 15:24:28 +0800 Subject: [PATCH 05/29] feat(stateful): expose TCB gateway per-port helpers and tool port scripts Add buildGatewayTarget/gatewayFetch for E2b-Sandbox-Port routing, CLI to describe/probe ports, and update-stateful-tool-ports (adds 5173). New tools from ensure-stateful-tool include vite/ttyd ports. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../server/scripts/describe-stateful-tool.ts | 66 +++++++++++ packages/server/scripts/probe-gateway-port.ts | 59 ++++++++++ .../scripts/update-stateful-tool-ports.ts | 105 ++++++++++++++++++ .../src/sandbox/ensure-stateful-tool.ts | 16 +-- .../src/sandbox/provider/stateful-provider.ts | 35 ++---- .../server/src/sandbox/stateful/gateway.ts | 105 ++++++++++++++++++ 6 files changed, 352 insertions(+), 34 deletions(-) create mode 100644 packages/server/scripts/describe-stateful-tool.ts create mode 100644 packages/server/scripts/probe-gateway-port.ts create mode 100644 packages/server/scripts/update-stateful-tool-ports.ts create mode 100644 packages/server/src/sandbox/stateful/gateway.ts diff --git a/packages/server/scripts/describe-stateful-tool.ts b/packages/server/scripts/describe-stateful-tool.ts new file mode 100644 index 0000000..88e06fb --- /dev/null +++ b/packages/server/scripts/describe-stateful-tool.ts @@ -0,0 +1,66 @@ +/** + * 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' + +const here = dirname(fileURLToPath(import.meta.url)) +config({ path: resolve(here, '../.env') }) + +async function callAgs(action: string, param: Record<string, unknown>) { + const managerModule = await import('@cloudbase/manager-node') + const managerUtilsModule = await import('@cloudbase/manager-node/lib/utils') + const CloudBase = ((managerModule as { default?: unknown }).default || managerModule) as new (cfg: object) => { + context: object + } + const CloudService = ((managerUtilsModule as { CloudService?: unknown; default?: { CloudService?: unknown } }) + .CloudService || + (managerUtilsModule as { default?: { CloudService?: unknown } }).default?.CloudService) as new ( + ctx: object, + svc: string, + ver: string, + ) => { request: (a: string, p: object) => Promise<Record<string, unknown>> } + + const secretId = process.env.TCB_SECRET_ID || process.env.TENCENTCLOUD_SECRET_ID || '' + const secretKey = process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_SECRET_KEY || '' + const envId = process.env.TCB_ENV_ID || '' + if (!secretId || !secretKey || !envId) throw new Error('TCB_ENV_ID / TCB_SECRET_ID / TCB_SECRET_KEY required') + + const app = new CloudBase({ secretId, secretKey, envId }) + const ags = new CloudService(app.context, 'ags', '2025-09-20') + return ags.request(action, param) +} + +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<Record<string, unknown>> | undefined)?.[0] + if (!tool) { + console.log('No tool found for', toolId) + process.exit(1) + } + + const cfg = tool.CustomConfiguration as Record<string, unknown> | 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 has5173 = ports.some((p) => p.Port === 5173) + console.log('vite 5173 declared:', has5173 ? 'yes' : 'NO — gateway E2b-Sandbox-Port:5173 will 500') +} + +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..de2568e --- /dev/null +++ b/packages/server/scripts/probe-gateway-port.ts @@ -0,0 +1,59 @@ +/** + * 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, + TRW_SERVICE_PORT, +} from '../src/sandbox/stateful/gateway.js' + +const here = dirname(fileURLToPath(import.meta.url)) +config({ path: resolve(here, '../.env') }) + +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 || '' + 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, + }) + : buildGatewayTarget({ envId, sandboxId, tcbApiKey, port, path }) + + 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 !== TRW_SERVICE_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/update-stateful-tool-ports.ts b/packages/server/scripts/update-stateful-tool-ports.ts new file mode 100644 index 0000000..13ed5fa --- /dev/null +++ b/packages/server/scripts/update-stateful-tool-ports.ts @@ -0,0 +1,105 @@ +/** + * Merge vite/ttyd ports into an existing stateful SDT (idempotent). + * + * pnpm exec tsx scripts/update-stateful-tool-ports.ts + */ + +import { config } from 'dotenv' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +config({ path: resolve(here, '../.env') }) + +const DESIRED_PORTS = [ + { Name: 'p9000', Protocol: 'TCP', Port: 9000 }, + { Name: 'p49983', Protocol: 'TCP', Port: 49983 }, + { Name: 'p7681', Protocol: 'TCP', Port: 7681 }, + { Name: 'p5173', Protocol: 'TCP', Port: 5173 }, + { Name: 'p3000', Protocol: 'TCP', Port: 3000 }, +] + +async function callAgs(action: string, param: Record<string, unknown>) { + const managerModule = await import('@cloudbase/manager-node') + const managerUtilsModule = await import('@cloudbase/manager-node/lib/utils') + const CloudBase = ((managerModule as { default?: unknown }).default || managerModule) as new (cfg: object) => { + context: object + } + const CloudService = ((managerUtilsModule as { CloudService?: unknown; default?: { CloudService?: unknown } }) + .CloudService || + (managerUtilsModule as { default?: { CloudService?: unknown } }).default?.CloudService) as new ( + ctx: object, + svc: string, + ver: string, + ) => { request: (a: string, p: object) => Promise<unknown> } + + const secretId = process.env.TCB_SECRET_ID || process.env.TENCENTCLOUD_SECRET_ID || '' + const secretKey = process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_SECRET_KEY || '' + const envId = process.env.TCB_ENV_ID || '' + if (!secretId || !secretKey || !envId) throw new Error('TCB_ENV_ID / TCB_SECRET_ID / TCB_SECRET_KEY required') + + const app = new CloudBase({ secretId, secretKey, envId }) + const ags = new CloudService(app.context, 'ags', '2025-09-20') + return ags.request(action, param) +} + +function mergePorts( + existing: Array<{ Name?: string; Port?: number; Protocol?: string }>, +): Array<{ Name: string; Protocol: string; Port: number }> { + const byPort = new Map<number, { Name: string; Protocol: string; Port: number }>() + for (const p of existing) { + if (typeof p.Port === 'number') { + byPort.set(p.Port, { + Name: p.Name || `p${p.Port}`, + Protocol: p.Protocol || 'TCP', + Port: p.Port, + }) + } + } + for (const p of DESIRED_PORTS) { + byPort.set(p.Port, p) + } + return [...byPort.values()].sort((a, b) => a.Port - b.Port) +} + +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 { + 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 ports = mergePorts(cfg.Ports || []) + const param = { + ToolId: toolId, + CustomConfiguration: { + Image: cfg.Image, + ImageRegistryType: process.env.STATEFUL_IMAGE_REGISTRY || 'personal', + Ports: 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 now:', + 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/src/sandbox/ensure-stateful-tool.ts b/packages/server/src/sandbox/ensure-stateful-tool.ts index 0634aef..e833f94 100644 --- a/packages/server/src/sandbox/ensure-stateful-tool.ts +++ b/packages/server/src/sandbox/ensure-stateful-tool.ts @@ -23,10 +23,7 @@ function resolveSandboxGatewayUrl(envId: string): string { return `https://${envId}.api.tcloudbasegateway.com/v1/sandbox/-` } -async function callAgsManagerApi( - action: string, - param: Record<string, unknown>, -): Promise<Record<string, unknown>> { +async function callAgsManagerApi(action: string, param: Record<string, unknown>): Promise<Record<string, unknown>> { const managerModule = await import('@cloudbase/manager-node') // @ts-expect-error manager-node ships utils without types const managerUtilsModule = await import('@cloudbase/manager-node/lib/utils') @@ -74,6 +71,8 @@ async function createSandboxTool(envId: string): Promise<string> { Ports: [ { Name: 'trw', Protocol: 'TCP', Port: 9000 }, { Name: 'envd', Protocol: 'TCP', Port: 49983 }, + { Name: 'vite', Protocol: 'TCP', Port: 5173 }, + { Name: 'ttyd', Protocol: 'TCP', Port: 7681 }, ], Probe: { HttpGet: { Path: '/health', Port: 9000, Scheme: 'HTTP' }, @@ -91,9 +90,7 @@ async function createSandboxTool(envId: string): Promise<string> { const resp = await callAgsManagerApi('CreateSandboxTool', data) const toolId = - (resp?.ToolId as string) || - ((resp?.data as Record<string, unknown> | undefined)?.ToolId as string) || - '' + (resp?.ToolId as string) || ((resp?.data as Record<string, unknown> | undefined)?.ToolId as string) || '' if (!toolId) { throw new Error(`CreateSandboxTool returned no ToolId: ${JSON.stringify(resp).slice(0, 300)}`) } @@ -142,10 +139,7 @@ async function persistToolId(envId: string, toolId: string, userId?: string, tas /** * Resolve ToolId for envId: DB → env override → CreateTool. */ -export async function ensureStatefulTool( - envId: string, - opts?: { userId?: string; taskId?: string }, -): Promise<string> { +export async function ensureStatefulTool(envId: string, opts?: { userId?: string; taskId?: string }): Promise<string> { const override = process.env.STATEFUL_TOOL_ID || process.env.STATEFUL_SANDBOX_TOOL_ID || '' if (override) return override diff --git a/packages/server/src/sandbox/provider/stateful-provider.ts b/packages/server/src/sandbox/provider/stateful-provider.ts index 73e1233..9cb31b8 100644 --- a/packages/server/src/sandbox/provider/stateful-provider.ts +++ b/packages/server/src/sandbox/provider/stateful-provider.ts @@ -20,10 +20,7 @@ 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 { normalizeSandboxMode, type SandboxInstanceMode } from '../../lib/sandbox-config.js' import type { AcquireContext, DeleteConversationContext, @@ -40,6 +37,7 @@ import type { } from './types.js' import { STATEFUL_WORKSPACE_ROOT } from '../../lib/sandbox-config.js' import { ensureStatefulTool, resolveStatefulGatewayUrl } from '../ensure-stateful-tool.js' +import { buildDataPlaneHeaders, TRW_SERVICE_PORT } from '../stateful/gateway.js' import { createStatefulMcpClient } from '../stateful/stateful-mcp-client.js' // ─── Constants ──────────────────────────────────────────────────────────── @@ -93,16 +91,6 @@ function readStatefulRuntimeConfig(envId: string, toolId: string): StatefulRunti } } -// ─── Auth headers builder ───────────────────────────────────────────────── - -function buildDataPlaneHeaders(opts: { tcbApiKey: string; sandboxId: string }): Record<string, string> { - return { - 'X-Cloudbase-Authorization': `Bearer ${opts.tcbApiKey}`, - 'E2b-Sandbox-Id': opts.sandboxId, - 'E2b-Sandbox-Port': '9000', - } -} - // ─── Instance meta bag ──────────────────────────────────────────────────── interface StatefulMetaBag { @@ -136,7 +124,7 @@ function buildStatefulInstance(args: { }): SandboxInstance { const { sandboxId, toolId, baseUrl, envId, conversationId, tcbApiKey, sandboxMode, cacheKey } = args const meta: StatefulMetaBag = { envId, conversationId, toolId, tcbApiKey, sandboxMode, cacheKey } - const authHeaders = buildDataPlaneHeaders({ tcbApiKey, sandboxId }) + const authHeaders = buildDataPlaneHeaders({ tcbApiKey, sandboxId, port: TRW_SERVICE_PORT }) return { backend: 'stateful', id: sandboxId, @@ -219,8 +207,7 @@ async function startStatefulInstance(cfg: StatefulRuntimeConfig, toolId: string) )) as Record<string, unknown> const data = result?.data as Record<string, unknown> | undefined const instanceObj = result?.Instance as Record<string, unknown> | undefined - const instanceId = - String(result?.InstanceId || instanceObj?.InstanceId || data?.InstanceId || '') || '' + const instanceId = String(result?.InstanceId || instanceObj?.InstanceId || data?.InstanceId || '') || '' if (!instanceId) { throw new Error(`StartSandboxInstance returned no InstanceId: ${JSON.stringify(result)}`) } @@ -340,7 +327,7 @@ async function checkHealth(baseUrl: string, headers: Record<string, string>): Pr } async function waitForReady(baseUrl: string, sandboxId: string, tcbApiKey: string): Promise<void> { - const headers = buildDataPlaneHeaders({ tcbApiKey, sandboxId }) + const headers = buildDataPlaneHeaders({ tcbApiKey, sandboxId, port: TRW_SERVICE_PORT }) const start = Date.now() while (Date.now() - start < READY_TIMEOUT_MS) { if (await checkHealth(baseUrl, headers)) return @@ -365,8 +352,7 @@ class StatefulProvider implements SandboxProvider { async acquire(ctx: AcquireContext, onProgress?: SandboxProgressCallback): Promise<SandboxInstance> { 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 preferredSandboxId = typeof ctx.meta?.preferredSandboxId === 'string' ? ctx.meta.preferredSandboxId : null const toolId = await ensureStatefulTool(ctx.envId, { userId, taskId: ctx.conversationId }) const cfg = readStatefulRuntimeConfig(ctx.envId, toolId) const key = this.cacheKey(ctx, sandboxMode) @@ -400,7 +386,11 @@ class StatefulProvider implements SandboxProvider { } // Final health check (covers pre-created path too). - const headers = buildDataPlaneHeaders({ tcbApiKey: cfg.tcbApiKey, sandboxId }) + const headers = buildDataPlaneHeaders({ + tcbApiKey: cfg.tcbApiKey, + sandboxId, + port: TRW_SERVICE_PORT, + }) if (!(await checkHealth(cfg.sandboxBaseUrl, headers))) { throw new Error(`Sandbox ${sandboxId} not healthy at ${cfg.sandboxBaseUrl}`) } @@ -470,8 +460,7 @@ class StatefulProvider implements SandboxProvider { async release(inst: SandboxInstance, ctx: ReleaseContext): Promise<void> { // AGS persistence: COS snapshot is auto-managed by TRW (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 + const flushSnapshot = ctx.backendOptions?.backend === 'stateful' && ctx.backendOptions.flushSnapshot === true if (!flushSnapshot) return try { diff --git a/packages/server/src/sandbox/stateful/gateway.ts b/packages/server/src/sandbox/stateful/gateway.ts new file mode 100644 index 0000000..6427e09 --- /dev/null +++ b/packages/server/src/sandbox/stateful/gateway.ts @@ -0,0 +1,105 @@ +/** + * TCB data-plane gateway helpers for stateful AGS instances. + * + * Browser iframes cannot send X-Cloudbase-Authorization / E2b-* headers; use OVC + * preview proxy or AGS direct URLs for UI. Server-side code uses these helpers. + */ + +import { resolveStatefulGatewayUrl } from '../ensure-stateful-tool.js' + +export const TRW_SERVICE_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 TRW preview use `/preview/{port}/...`. */ + path: string + headers: Record<string, string> + url: string +} + +export function buildDataPlaneHeaders(opts: { + tcbApiKey: string + sandboxId: string + port: number +}): Record<string, string> { + return { + 'X-Cloudbase-Authorization': `Bearer ${opts.tcbApiKey}`, + 'E2b-Sandbox-Id': opts.sandboxId, + 'E2b-Sandbox-Port': String(opts.port), + } +} + +/** + * Build a gateway request target for a specific container port. + * + * - port 9000: TRW 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 +}): 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, + }) + return { + baseUrl, + sandboxId: args.sandboxId, + port: args.port, + path, + headers, + url: `${baseUrl}${path}`, + } +} + +/** TRW reverse-proxy path for a dev server port (always via TRW :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 +}): StatefulGatewayTarget { + return buildGatewayTarget({ + ...args, + port: TRW_SERVICE_PORT, + path: buildTrwPreviewPath(args.vitePort, args.subpath), + }) +} + +export async function gatewayFetch( + target: StatefulGatewayTarget, + init?: RequestInit, +): Promise<Response> { + return fetch(target.url, { + ...init, + headers: { + ...target.headers, + ...((init?.headers as Record<string, string> | undefined) ?? {}), + }, + }) +} + +function normalizePath(path: string): string { + if (!path || path === '/') return '/' + return path.startsWith('/') ? path : `/${path}` +} From 5c6aa1f58c31b8e5cd7c33e4584e3f07792c2943 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Thu, 21 May 2026 17:18:25 +0800 Subject: [PATCH 06/29] feat(stateful): template warmup UX and clearer AGS sandbox errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 10s×6 polling after tool create and on retryable StartSandboxInstance failures so users see template_create/template_warmup instead of opaque InternalError stalls. Format AGS errors with RequestId for support, and add debug-start-instance script for operator repro. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../server/scripts/debug-start-instance.ts | 61 ++++++++++++ .../server/scripts/describe-stateful-tool.ts | 3 +- .../scripts/update-stateful-tool-image.ts | 10 +- .../scripts/update-stateful-tool-ports.ts | 8 +- .../server/scripts/verify-stateful-e2e.ts | 44 ++++----- .../src/agent/cloudbase-agent.service.ts | 44 ++++----- packages/server/src/agent/coding-mode.ts | 4 +- .../server/src/agent/runtime/base-runtime.ts | 13 ++- .../cloudbase-mcp-inject-credentials.test.ts | 4 +- packages/server/src/routes/admin.ts | 5 +- packages/server/src/sandbox/ags-error.ts | 93 +++++++++++++++++++ .../src/sandbox/ensure-stateful-tool.ts | 12 ++- .../src/sandbox/provider/stateful-provider.ts | 23 ++++- .../src/sandbox/stateful-tool-warmup.ts | 51 ++++++++++ .../chat/agent-status-indicator.tsx | 4 + .../src/components/chat/browser-controls.tsx | 3 +- packages/web/src/components/task-details.tsx | 30 +++--- packages/web/src/hooks/use-preview-bridge.ts | 3 +- .../web/src/pages/admin/settings-page.tsx | 10 +- packages/web/vite.config.ts | 1 + 20 files changed, 313 insertions(+), 113 deletions(-) create mode 100644 packages/server/scripts/debug-start-instance.ts create mode 100644 packages/server/src/sandbox/ags-error.ts create mode 100644 packages/server/src/sandbox/stateful-tool-warmup.ts diff --git a/packages/server/scripts/debug-start-instance.ts b/packages/server/scripts/debug-start-instance.ts new file mode 100644 index 0000000..741be2b --- /dev/null +++ b/packages/server/scripts/debug-start-instance.ts @@ -0,0 +1,61 @@ +/** + * Debug StartSandboxInstance — prints full AGS error + tool CustomConfiguration. + */ +import { config } from 'dotenv' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +config({ path: resolve(here, '../.env') }) + +async function callAgs(action: string, param: Record<string, unknown>) { + const managerModule = await import('@cloudbase/manager-node') + const managerUtilsModule = await import('@cloudbase/manager-node/lib/utils') + const CloudBase = ((managerModule as { default?: unknown }).default || managerModule) as new (cfg: object) => { + context: object + } + const CloudService = ((managerUtilsModule as { CloudService?: unknown; default?: { CloudService?: unknown } }) + .CloudService || (managerUtilsModule as { default?: { CloudService?: unknown } }).default?.CloudService) as new ( + ctx: object, + svc: string, + ver: string, + ) => { request: (a: string, p: object) => Promise<unknown> } + + const secretId = process.env.TCB_SECRET_ID || process.env.TENCENTCLOUD_SECRET_ID || '' + const secretKey = process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_SECRET_KEY || '' + const envId = process.env.TCB_ENV_ID || '' + const app = new CloudBase({ secretId, secretKey, envId }) + const ags = new CloudService(app.context, 'ags', '2025-09-20') + return ags.request(action, param) +} + +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<string, unknown> + const tool = (list.SandboxToolSet as Array<Record<string, unknown>> | undefined)?.[0] + console.log('=== Tool CustomConfiguration ===') + console.log(JSON.stringify(tool?.CustomConfiguration, null, 2)) + + console.log('\n=== StartSandboxInstance ===') + try { + const resp = await callAgs('StartSandboxInstance', { + ToolId: toolId, + Timeout: '30m', + AuthMode: 'NONE', + }) + 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 index 88e06fb..45d7331 100644 --- a/packages/server/scripts/describe-stateful-tool.ts +++ b/packages/server/scripts/describe-stateful-tool.ts @@ -18,8 +18,7 @@ async function callAgs(action: string, param: Record<string, unknown>) { context: object } const CloudService = ((managerUtilsModule as { CloudService?: unknown; default?: { CloudService?: unknown } }) - .CloudService || - (managerUtilsModule as { default?: { CloudService?: unknown } }).default?.CloudService) as new ( + .CloudService || (managerUtilsModule as { default?: { CloudService?: unknown } }).default?.CloudService) as new ( ctx: object, svc: string, ver: string, diff --git a/packages/server/scripts/update-stateful-tool-image.ts b/packages/server/scripts/update-stateful-tool-image.ts index 205238a..f0b9ced 100644 --- a/packages/server/scripts/update-stateful-tool-image.ts +++ b/packages/server/scripts/update-stateful-tool-image.ts @@ -20,8 +20,7 @@ async function callAgs(action: string, param: Record<string, unknown>) { context: object } const CloudService = ((managerUtilsModule as { CloudService?: unknown; default?: { CloudService?: unknown } }) - .CloudService || - (managerUtilsModule as { default?: { CloudService?: unknown } }).default?.CloudService) as new ( + .CloudService || (managerUtilsModule as { default?: { CloudService?: unknown } }).default?.CloudService) as new ( ctx: object, svc: string, ver: string, @@ -53,10 +52,17 @@ async function main() { }, } + 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) diff --git a/packages/server/scripts/update-stateful-tool-ports.ts b/packages/server/scripts/update-stateful-tool-ports.ts index 13ed5fa..2bb7923 100644 --- a/packages/server/scripts/update-stateful-tool-ports.ts +++ b/packages/server/scripts/update-stateful-tool-ports.ts @@ -26,8 +26,7 @@ async function callAgs(action: string, param: Record<string, unknown>) { context: object } const CloudService = ((managerUtilsModule as { CloudService?: unknown; default?: { CloudService?: unknown } }) - .CloudService || - (managerUtilsModule as { default?: { CloudService?: unknown } }).default?.CloudService) as new ( + .CloudService || (managerUtilsModule as { default?: { CloudService?: unknown } }).default?.CloudService) as new ( ctx: object, svc: string, ver: string, @@ -87,10 +86,7 @@ async function main() { try { const resp = await callAgs(action, param) console.log(`[update-stateful-tool-ports] ${action} ok`, JSON.stringify(resp).slice(0, 400)) - console.log( - 'Ports now:', - ports.map((p) => p.Port).join(', '), - ) + console.log('Ports now:', ports.map((p) => p.Port).join(', ')) return } catch (err) { console.warn(`[update-stateful-tool-ports] ${action} failed:`, (err as Error).message) diff --git a/packages/server/scripts/verify-stateful-e2e.ts b/packages/server/scripts/verify-stateful-e2e.ts index 8daa02a..e1bd42c 100644 --- a/packages/server/scripts/verify-stateful-e2e.ts +++ b/packages/server/scripts/verify-stateful-e2e.ts @@ -80,10 +80,7 @@ async function main() { emit('config_tool_id', !!(process.env.STATEFUL_TOOL_ID || process.env.STATEFUL_SANDBOX_TOOL_ID)) emit( 'config_gateway_url', - !!( - process.env.STATEFUL_GATEWAY_URL?.includes('tcloudbasegateway.com') || - process.env.TCB_ENV_ID - ), + !!(process.env.STATEFUL_GATEWAY_URL?.includes('tcloudbasegateway.com') || process.env.TCB_ENV_ID), ) emit('config_tcb_api_key', !!process.env.TCB_API_KEY) @@ -103,18 +100,15 @@ async function main() { try { const tInit = Date.now() - const session = await provider.prepare( - inst, - { - credentials: { - envId: process.env.TCB_ENV_ID || '', - secretId: process.env.TCB_SECRET_ID || process.env.TENCENTCLOUD_SECRET_ID || '', - secretKey: process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_SECRET_KEY || '', - }, - codingMode: true, - backendOptions: { backend: 'stateful' }, + const session = await provider.prepare(inst, { + credentials: { + envId: process.env.TCB_ENV_ID || '', + secretId: process.env.TCB_SECRET_ID || process.env.TENCENTCLOUD_SECRET_ID || '', + secretKey: process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_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) @@ -144,27 +138,21 @@ async function main() { } try { - const previewRes = await inst.request(`/preview/${VITE_PORT}/`, { + 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( - 'trw_preview_vite', - previewRes.status < 500 && body.length > 0, + 'gateway_vite_root', + previewRes.status >= 200 && previewRes.status < 400 && body.length > 0, `status=${previewRes.status} bytes=${body.length}`, ) } catch (e) { - emit('trw_preview_vite', false, (e as Error).message) - } - - try { - const portsRes = await inst.request('/preview/ports', { signal: AbortSignal.timeout(10_000) }) - const portsJson = (await portsRes.json()) as { ports?: Array<{ port: number }> } - const hasVite = Array.isArray(portsJson.ports) && portsJson.ports.some((p) => p.port === VITE_PORT) - emit('trw_preview_ports', portsRes.ok && hasVite, JSON.stringify(portsJson.ports?.map((p) => p.port) ?? [])) - } catch (e) { - emit('trw_preview_ports', false, (e as Error).message) + emit('gateway_vite_root', false, (e as Error).message) } const healthRes = await inst.request('/health', { signal: AbortSignal.timeout(10_000) }) diff --git a/packages/server/src/agent/cloudbase-agent.service.ts b/packages/server/src/agent/cloudbase-agent.service.ts index e4345f6..09e6564 100644 --- a/packages/server/src/agent/cloudbase-agent.service.ts +++ b/packages/server/src/agent/cloudbase-agent.service.ts @@ -7,6 +7,7 @@ import { v4 as uuidv4 } from 'uuid' import { loadConfig } from '../config/store.js' import { persistenceService } from './persistence.service.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, @@ -17,11 +18,7 @@ import type { import { archiveToGit } from '../sandbox/git-archive.js' import { getCodingSystemPrompt } from './coding-mode.js' import { getDb } from '../db/index.js' -import { - STATEFUL_WORKSPACE_ROOT, - resolveSandboxConfig, - backfillSandboxConfig, -} from '../lib/sandbox-config.js' +import { STATEFUL_WORKSPACE_ROOT, resolveSandboxConfig, backfillSandboxConfig } 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' @@ -195,7 +192,12 @@ export function clearModelsCache(): void { // ─── Sandbox & Prompt Helpers (imported from base-runtime) ─────────────── // 集中在 base-runtime.ts 维护,所有 runtime 共用。 -import { WRITE_TOOLS, buildAppendPrompt, getPublishableKey, persistDeploymentFromArtifact } from './runtime/base-runtime.js' +import { + WRITE_TOOLS, + buildAppendPrompt, + getPublishableKey, + persistDeploymentFromArtifact, +} from './runtime/base-runtime.js' // ─── Types ───────────────────────────────────────────────────────────────── @@ -561,7 +563,9 @@ export class CloudbaseAgentService { sandboxConfig = resolveSandboxConfig({ envId: userContext.envId, taskId: conversationId }) } - const taskForAcquire = await getDb().tasks.findById(conversationId).catch(() => null) + const taskForAcquire = await getDb() + .tasks.findById(conversationId) + .catch(() => null) const { sandboxCwd: resolvedCwd } = sandboxConfig // Remote TRW workspace path (semantic only on stateful; tools run in sandbox via MCP). @@ -571,9 +575,7 @@ export class CloudbaseAgentService { workspaceCwd === STATEFUL_WORKSPACE_ROOT || workspaceCwd.startsWith('/home/user') ? path.join(os.tmpdir(), 'ovc-agent', conversationId) : workspaceCwd - console.log( - `[Agent] sandboxConfig: workspaceCwd=${workspaceCwd}, localCwd=${localCwd}, cwd=${cwd ?? '(none)'}`, - ) + console.log(`[Agent] sandboxConfig: workspaceCwd=${workspaceCwd}, localCwd=${localCwd}, cwd=${cwd ?? '(none)'}`) mkdirSync(localCwd, { recursive: true }) // ── 复制 .codebuddy/models.json 模板供 SDK 读取自定义模型 ──────────── @@ -917,10 +919,12 @@ export class CloudbaseAgentService { } } } 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 } @@ -1254,20 +1258,8 @@ export class CloudbaseAgentService { append: isCodingMode ? getCodingSystemPrompt(userContext.envId, publishableKey) + '\n\n' + - buildAppendPrompt( - workspaceCwd, - conversationId, - userContext.envId, - sandboxConfig.sandboxMode, - true, - ) - : buildAppendPrompt( - workspaceCwd, - conversationId, - userContext.envId, - sandboxConfig.sandboxMode, - false, - ), + buildAppendPrompt(workspaceCwd, conversationId, userContext.envId, sandboxConfig.sandboxMode, true) + : buildAppendPrompt(workspaceCwd, conversationId, userContext.envId, sandboxConfig.sandboxMode, false), }, mcpServers, abortController, diff --git a/packages/server/src/agent/coding-mode.ts b/packages/server/src/agent/coding-mode.ts index a452696..81faf39 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=/ (TRW 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=/ (TRW 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({ diff --git a/packages/server/src/agent/runtime/base-runtime.ts b/packages/server/src/agent/runtime/base-runtime.ts index eb66a8a..0f52a88 100644 --- a/packages/server/src/agent/runtime/base-runtime.ts +++ b/packages/server/src/agent/runtime/base-runtime.ts @@ -19,6 +19,7 @@ import type { AgentCallback, AgentCallbackMessage, AgentOptions } from '@coder/s import type { ChatStreamResult, IAgentRuntime } from './types.js' import type { ModelInfo } from '../cloudbase-agent.service.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, @@ -28,11 +29,7 @@ import type { } from '../../sandbox/provider/types.js' import { getDb } from '../../db/index.js' import type { Task } from '../../db/types.js' -import { - STATEFUL_WORKSPACE_ROOT, - resolveSandboxConfig, - backfillSandboxConfig, -} from '../../lib/sandbox-config.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' @@ -471,11 +468,13 @@ export abstract class BaseAgentRuntime implements IAgentRuntime { } } } 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`, }) } } 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 fcfed02..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 @@ -70,9 +70,7 @@ function mockUserResource( describe('createInjectCredentials', () => { it('uses permanent credentials when camSecretId/camSecretKey present', async () => { - findByUserIdMock.mockResolvedValue( - mockUserResource({ camSecretId: 'AKID-PERM', camSecretKey: 'KEY-PERM' }), - ) + findByUserIdMock.mockResolvedValue(mockUserResource({ camSecretId: 'AKID-PERM', camSecretKey: 'KEY-PERM' })) const fetcher = makeFetch(async () => makeOkResponse()) const fn = createInjectCredentials({ diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts index f9a06ec..262d46e 100644 --- a/packages/server/src/routes/admin.ts +++ b/packages/server/src/routes/admin.ts @@ -4,10 +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 { isValidSandboxInstanceMode, resolveSandboxInstanceMode } from '../lib/sandbox-config.js' import { persistenceService } from '../agent/persistence.service.js' import { nanoid } from 'nanoid' import bcrypt from 'bcryptjs' diff --git a/packages/server/src/sandbox/ags-error.ts b/packages/server/src/sandbox/ags-error.ts new file mode 100644 index 0000000..e70c0a6 --- /dev/null +++ b/packages/server/src/sandbox/ags-error.ts @@ -0,0 +1,93 @@ +/** + * 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 index e833f94..30213f1 100644 --- a/packages/server/src/sandbox/ensure-stateful-tool.ts +++ b/packages/server/src/sandbox/ensure-stateful-tool.ts @@ -6,6 +6,8 @@ import { nanoid } from 'nanoid' 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' export const STATEFUL_TOOL_SETTINGS_KEY = 'stateful_tool_id' @@ -139,15 +141,23 @@ async function persistToolId(envId: string, toolId: string, userId?: string, tas /** * Resolve ToolId for envId: DB → env override → CreateTool. */ -export async function ensureStatefulTool(envId: string, opts?: { userId?: string; taskId?: string }): Promise<string> { +export async function ensureStatefulTool( + envId: string, + opts?: { userId?: string; taskId?: string; onProgress?: SandboxProgressCallback }, +): Promise<string> { const override = process.env.STATEFUL_TOOL_ID || process.env.STATEFUL_SANDBOX_TOOL_ID || '' if (override) return override const existing = await readStoredToolId(envId, opts?.userId, opts?.taskId) if (existing) return existing + 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 } diff --git a/packages/server/src/sandbox/provider/stateful-provider.ts b/packages/server/src/sandbox/provider/stateful-provider.ts index 9cb31b8..f73484c 100644 --- a/packages/server/src/sandbox/provider/stateful-provider.ts +++ b/packages/server/src/sandbox/provider/stateful-provider.ts @@ -37,6 +37,7 @@ import type { } from './types.js' import { STATEFUL_WORKSPACE_ROOT } from '../../lib/sandbox-config.js' import { ensureStatefulTool, resolveStatefulGatewayUrl } from '../ensure-stateful-tool.js' +import { startStatefulInstanceWithWarmup } from '../stateful-tool-warmup.js' import { buildDataPlaneHeaders, TRW_SERVICE_PORT } from '../stateful/gateway.js' import { createStatefulMcpClient } from '../stateful/stateful-mcp-client.js' @@ -266,12 +267,16 @@ function pickPrimaryInstance(candidates: StatefulInstanceStatus[]): StatefulInst 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) { - const sandboxId = await startStatefulInstance(cfg, toolId) + const sandboxId = await startStatefulInstanceWithWarmup( + () => startStatefulInstance(cfg, toolId), + onProgress, + ) return { sandboxId, created: true } } @@ -296,6 +301,7 @@ 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] }) @@ -308,7 +314,10 @@ async function ensureTaskInstance( } } - const sandboxId = await startStatefulInstance(cfg, toolId) + const sandboxId = await startStatefulInstanceWithWarmup( + () => startStatefulInstance(cfg, toolId), + onProgress, + ) return { sandboxId, created: true } } @@ -353,7 +362,11 @@ class StatefulProvider implements SandboxProvider { 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 }) + const toolId = await ensureStatefulTool(ctx.envId, { + userId, + taskId: ctx.conversationId, + onProgress, + }) const cfg = readStatefulRuntimeConfig(ctx.envId, toolId) const key = this.cacheKey(ctx, sandboxMode) @@ -375,10 +388,10 @@ class StatefulProvider implements SandboxProvider { } else { onProgress?.({ phase: 'create', message: '正在启动云端沙箱实例...\n' }) if (sandboxMode === 'isolated') { - const ensured = await ensureTaskInstance(cfg, cfg.toolId, preferredSandboxId) + const ensured = await ensureTaskInstance(cfg, cfg.toolId, preferredSandboxId, onProgress) sandboxId = ensured.sandboxId } else { - const ensured = await ensureSingleEnvInstance(cfg, cfg.toolId) + const ensured = await ensureSingleEnvInstance(cfg, cfg.toolId, onProgress) sandboxId = ensured.sandboxId } onProgress?.({ phase: 'wait_ready', message: '等待沙箱实例就绪...\n' }) 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..7ca628f --- /dev/null +++ b/packages/server/src/sandbox/stateful-tool-warmup.ts @@ -0,0 +1,51 @@ +/** + * 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<void> { + return new Promise((r) => setTimeout(r, ms)) +} + +function emitWarmupProgress(onProgress: SandboxProgressCallback | undefined, round: number, hint?: string): void { + const suffix = hint ? `(${hint})` : '' + onProgress?.({ + phase: 'template_warmup', + message: `沙箱模板预热中 ${round}/${WARMUP_POLL_MAX}${suffix}...\n`, + }) +} + +/** After CreateSandboxTool: short poll window before first StartSandboxInstance. */ +export async function waitStatefulToolImageWarmup(onProgress?: SandboxProgressCallback): Promise<void> { + for (let round = 1; round <= WARMUP_POLL_MAX; round++) { + emitWarmupProgress(onProgress, round, '云平台拉取镜像') + await sleep(WARMUP_POLL_MS) + } +} + +/** Start instance; on InternalError poll 10s × up to 6 attempts (一条龙 #24). */ +export async function startStatefulInstanceWithWarmup( + start: () => Promise<string>, + onProgress?: SandboxProgressCallback, +): Promise<string> { + 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 + } + emitWarmupProgress(onProgress, attempt, '镜像尚未就绪,稍后重试') + await sleep(WARMUP_POLL_MS) + } + } + throw lastErr +} diff --git a/packages/web/src/components/chat/agent-status-indicator.tsx b/packages/web/src/components/chat/agent-status-indicator.tsx index 4e4e707..4a8071c 100644 --- a/packages/web/src/components/chat/agent-status-indicator.tsx +++ b/packages/web/src/components/chat/agent-status-indicator.tsx @@ -43,6 +43,10 @@ const PHASE_CONFIG: Record<AgentPhaseName, PhaseConfig> = { iconClass: 'text-blue-500', label: (toolName) => { switch (toolName) { + case 'sandbox:template_create': + return '沙箱模板生成中(本环境仅首次)...' + case 'sandbox:template_warmup': + return '沙箱模板预热中...' case 'sandbox:reuse': return '连接已有沙箱...' case 'sandbox:create': diff --git a/packages/web/src/components/chat/browser-controls.tsx b/packages/web/src/components/chat/browser-controls.tsx index 6238cff..b600094 100644 --- a/packages/web/src/components/chat/browser-controls.tsx +++ b/packages/web/src/components/chat/browser-controls.tsx @@ -165,8 +165,7 @@ function HmrIndicator({ status }: { status: HmrStatus }) { */ function extractDisplayPath(url: string): string { try { - const base = - typeof window !== 'undefined' ? window.location.origin : 'http://localhost' + const base = typeof window !== 'undefined' ? window.location.origin : 'http://localhost' const u = new URL(url, base) const proxyMatch = /^\/api\/tasks\/[^/]+\/preview\/\d+/.exec(u.pathname) if (proxyMatch) { diff --git a/packages/web/src/components/task-details.tsx b/packages/web/src/components/task-details.tsx index 3e0be16..c6e3260 100644 --- a/packages/web/src/components/task-details.tsx +++ b/packages/web/src/components/task-details.tsx @@ -2477,9 +2477,7 @@ export function TaskDetails({ <BrowserControls previewUrl={ previewGatewayUrl || - (codingPreviewCanLoad - ? `/api/tasks/${task.id}/preview/5173/` - : 'http://localhost:5173') + (codingPreviewCanLoad ? `/api/tasks/${task.id}/preview/5173/` : 'http://localhost:5173') } bridge={previewBridge} onHardRefresh={() => { @@ -2587,26 +2585,26 @@ export function TaskDetails({ {/* 内容区 */} <div className="relative flex-1 min-h-0"> {/* 沙箱尚未分配 */} - {!codingPreviewCanLoad && !previewGatewayLoading && !previewGatewayUrl && !previewGatewayError && ( - <div className="absolute inset-0 flex items-center justify-center"> - <div className="flex flex-col items-center gap-2 text-sm text-muted-foreground text-center"> - <Loader2 className="h-5 w-5 animate-spin" /> - <span>AI 正在初始化项目,请稍候...</span> - </div> - </div> - )} - {/* 沙箱已有但 preview-url 尚未返回(避免空白 + 地址栏假 /) */} - {codingPreviewCanLoad && + {!codingPreviewCanLoad && !previewGatewayLoading && !previewGatewayUrl && !previewGatewayError && ( - <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-[5]"> - <div className="flex flex-col items-center gap-2 text-sm text-muted-foreground bg-background/80 backdrop-blur rounded-md px-4 py-3 shadow text-center"> + <div className="absolute inset-0 flex items-center justify-center"> + <div className="flex flex-col items-center gap-2 text-sm text-muted-foreground text-center"> <Loader2 className="h-5 w-5 animate-spin" /> - <span>正在连接预览网关…</span> + <span>AI 正在初始化项目,请稍候...</span> </div> </div> )} + {/* 沙箱已有但 preview-url 尚未返回(避免空白 + 地址栏假 /) */} + {codingPreviewCanLoad && !previewGatewayLoading && !previewGatewayUrl && !previewGatewayError && ( + <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-[5]"> + <div className="flex flex-col items-center gap-2 text-sm text-muted-foreground bg-background/80 backdrop-blur rounded-md px-4 py-3 shadow text-center"> + <Loader2 className="h-5 w-5 animate-spin" /> + <span>正在连接预览网关…</span> + </div> + </div> + )} {/* Loading 状态:实时显示后端推送的进度 */} {previewGatewayLoading && ( <> diff --git a/packages/web/src/hooks/use-preview-bridge.ts b/packages/web/src/hooks/use-preview-bridge.ts index 415c542..75d4f6c 100644 --- a/packages/web/src/hooks/use-preview-bridge.ts +++ b/packages/web/src/hooks/use-preview-bridge.ts @@ -99,8 +99,7 @@ export function usePreviewBridge(options: UsePreviewBridgeOptions): PreviewBridg let iframeOrigin: string | null = null try { - const base = - typeof window !== 'undefined' ? window.location.origin : 'http://localhost' + const base = typeof window !== 'undefined' ? window.location.origin : 'http://localhost' iframeOrigin = new URL(previewUrl, base).origin } catch { iframeOrigin = null diff --git a/packages/web/src/pages/admin/settings-page.tsx b/packages/web/src/pages/admin/settings-page.tsx index e3dce80..a4006c6 100644 --- a/packages/web/src/pages/admin/settings-page.tsx +++ b/packages/web/src/pages/admin/settings-page.tsx @@ -262,16 +262,12 @@ export function AdminSettingsPage() { 控制同一 CloudBase 环境下,多个任务是否共用同一个 AGS 运行时实例(与上方「环境隔离」正交) </p> <p className="text-xs text-muted-foreground"> - 优先级:管理员设置(DB) > <code className="px-1 rounded bg-muted">SANDBOX_INSTANCE_MODE</code>{' '} - > 默认(provision=task 时为 isolated,否则 shared) + 优先级:管理员设置(DB) > <code className="px-1 rounded bg-muted">SANDBOX_INSTANCE_MODE</code> > + 默认(provision=task 时为 isolated,否则 shared) </p> </div> - <RadioGroup - value={sandboxInstanceMode} - onValueChange={setSandboxInstanceMode} - className="space-y-3" - > + <RadioGroup value={sandboxInstanceMode} onValueChange={setSandboxInstanceMode} className="space-y-3"> {SANDBOX_INSTANCE_MODES.map((mode) => ( <label key={mode.value} diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 69c815a..1cfa8f5 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ }, server: { port: 5174, + strictPort: true, host: '0.0.0.0', proxy: { '/api': { From 0da8dbf76466cc3de64c3e6a2c8430ee1f87a41c Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Thu, 21 May 2026 17:23:44 +0800 Subject: [PATCH 07/29] chore: gitignore server sqlite at package root and format Ignore packages/server/*.db from local dev, remove ad-hoc CodeBuddy probe scripts, and apply Prettier to sandbox modules left dirty after the prior commit hook. Co-authored-by: Cursor <cursoragent@cursor.com> --- .gitignore | 3 ++ .../scripts/update-stateful-tool-image.ts | 5 ++- packages/server/src/lib/sandbox-config.ts | 4 +-- packages/server/src/routes/tasks.ts | 35 ++++++------------- .../sandbox/__tests__/preview-proxy.test.ts | 3 +- packages/server/src/sandbox/ags-error.ts | 16 +++++---- packages/server/src/sandbox/index.ts | 7 +++- packages/server/src/sandbox/preview-proxy.ts | 7 +--- .../server/src/sandbox/preview-ws-proxy.ts | 4 +-- .../src/sandbox/provider/stateful-provider.ts | 10 ++---- .../src/sandbox/stateful/e2b-native-client.ts | 6 +++- .../server/src/sandbox/stateful/gateway.ts | 5 +-- packages/server/src/sandbox/task-sandbox.ts | 16 ++++----- packages/server/src/sandbox/ws-auth.ts | 5 +-- 14 files changed, 50 insertions(+), 76 deletions(-) diff --git a/.gitignore b/.gitignore index b65d33b..9c7c545 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/server/scripts/update-stateful-tool-image.ts b/packages/server/scripts/update-stateful-tool-image.ts index f0b9ced..c02f88f 100644 --- a/packages/server/scripts/update-stateful-tool-image.ts +++ b/packages/server/scripts/update-stateful-tool-image.ts @@ -52,9 +52,8 @@ async function main() { }, } - const { STATEFUL_TOOL_WARMUP_POLL_MS, STATEFUL_TOOL_WARMUP_POLL_MAX } = await import( - '../src/sandbox/stateful-tool-warmup.js' - ) + 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 { diff --git a/packages/server/src/lib/sandbox-config.ts b/packages/server/src/lib/sandbox-config.ts index b3c7e99..82d7c63 100644 --- a/packages/server/src/lib/sandbox-config.ts +++ b/packages/server/src/lib/sandbox-config.ts @@ -89,9 +89,7 @@ export function isValidSandboxInstanceMode(val: string): val is SandboxInstanceM } /** Default sandbox instance mode when creating a task (honours body override). */ -export async function resolveSandboxModeForNewTask( - bodyMode?: string | null, -): Promise<SandboxInstanceMode> { +export async function resolveSandboxModeForNewTask(bodyMode?: string | null): Promise<SandboxInstanceMode> { if (bodyMode && isValidSandboxInstanceMode(bodyMode)) return bodyMode return (await resolveSandboxInstanceMode()).value } diff --git a/packages/server/src/routes/tasks.ts b/packages/server/src/routes/tasks.ts index e901dd3..0581158 100644 --- a/packages/server/src/routes/tasks.ts +++ b/packages/server/src/routes/tasks.ts @@ -401,7 +401,7 @@ tasksRouter.post('/', async (c) => { error: null, branchName: null, sandboxId: null, - sandboxCwd: sandboxConfig?.sandboxCwd ?? null, + sandboxCwd: sandboxConfig?.sandboxCwd ?? null, sandboxMode: sandboxConfig?.sandboxMode ?? null, agentSessionId: null, sandboxUrl: null, @@ -459,19 +459,14 @@ tasksRouter.patch('/:taskId', async (c) => { }) /** 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', -]) +const BULK_DELETE_STATUSES = new Set(['completed', 'done', 'error', 'failed', 'stopped', 'processing', 'pending']) function resolveBulkDeleteStatuses(action: string): Set<string> { const out = new Set<string>() - for (const raw of action.split(',').map((s) => s.trim()).filter(Boolean)) { + for (const raw of action + .split(',') + .map((s) => s.trim()) + .filter(Boolean)) { if (raw === 'failed') { out.add('error') continue @@ -857,10 +852,7 @@ tasksRouter.get('/:taskId/files', requireUserEnv, async (c) => { .trim() .split('\n') .filter((line) => line.trim()) - const checkRemoteResult = await runCommandInSandbox( - 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 runCommandInSandbox(sandbox, `git diff --numstat ${compareRef}`) @@ -1493,10 +1485,7 @@ tasksRouter.get('/:taskId/diff', requireUserEnv, async (c) => { const sandbox = await getTaskSandbox(task, envId) if (!sandbox) return c.json({ error: 'Sandbox not found or inactive' }, 400) await runCommandInSandbox(sandbox, `git fetch origin ${task.branchName}`) - const checkRemoteResult = await runCommandInSandbox( - sandbox, - `git rev-parse --verify origin/${task.branchName}`, - ) + const checkRemoteResult = await runCommandInSandbox(sandbox, `git rev-parse --verify origin/${task.branchName}`) const remoteBranchExists = checkRemoteResult.success if (!remoteBranchExists) { const oldContentResult = await runCommandInSandbox(sandbox, `git show HEAD:${filename}`) @@ -2435,9 +2424,7 @@ tasksRouter.get('/:taskId/preview-health', requireUserEnv, async (c) => { } 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 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) { @@ -2844,9 +2831,7 @@ tasksRouter.get('/:taskId/preview-errors', requireUserEnv, async (c) => { 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 + vitePort = ports.includes(STATEFUL_DEFAULT_VITE_PORT) ? STATEFUL_DEFAULT_VITE_PORT : (ports[0] ?? null) } } catch { // preview/ports unavailable → treat as no vite diff --git a/packages/server/src/sandbox/__tests__/preview-proxy.test.ts b/packages/server/src/sandbox/__tests__/preview-proxy.test.ts index ae0b6f8..dcef8f2 100644 --- a/packages/server/src/sandbox/__tests__/preview-proxy.test.ts +++ b/packages/server/src/sandbox/__tests__/preview-proxy.test.ts @@ -4,8 +4,7 @@ import { rewritePreviewPaths } from '../preview-proxy.js' describe('rewritePreviewPaths', () => { it('rewrites vite base asset URLs to task preview proxy', () => { const html = - '<script type="module" src="/preview/5173/@vite/client"></script>' + - '<link href="/preview/5173/src/main.css">' + '<script type="module" src="/preview/5173/@vite/client"></script>' + '<link href="/preview/5173/src/main.css">' const out = rewritePreviewPaths(html, 'task-1', '5173') expect(out).toContain('/api/tasks/task-1/preview/5173/@vite/client') expect(out).not.toContain('"/preview/5173/') diff --git a/packages/server/src/sandbox/ags-error.ts b/packages/server/src/sandbox/ags-error.ts index e70c0a6..66474d5 100644 --- a/packages/server/src/sandbox/ags-error.ts +++ b/packages/server/src/sandbox/ags-error.ts @@ -29,13 +29,15 @@ export function unwrapManagerApiError(err: unknown): ManagerErr { 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 + ( + 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 { diff --git a/packages/server/src/sandbox/index.ts b/packages/server/src/sandbox/index.ts index 6f25768..d2aa940 100644 --- a/packages/server/src/sandbox/index.ts +++ b/packages/server/src/sandbox/index.ts @@ -35,7 +35,12 @@ export { type GitArchiveConfig, } from './git-archive.js' -export { overrideTools, type ToolOverrideConfig as LegacyToolOverrideConfig, type ToolResult, type ToolContext } from './tool-override.js' +export { + overrideTools, + type ToolOverrideConfig as LegacyToolOverrideConfig, + type ToolResult, + type ToolContext, +} from './tool-override.js' export { statefulReadTextFile, diff --git a/packages/server/src/sandbox/preview-proxy.ts b/packages/server/src/sandbox/preview-proxy.ts index af08229..94cdc16 100644 --- a/packages/server/src/sandbox/preview-proxy.ts +++ b/packages/server/src/sandbox/preview-proxy.ts @@ -60,12 +60,7 @@ function shouldRewriteBody(contentType: string | null): boolean { ) } -function rewriteLocationHeader( - location: string, - taskId: string, - port: string, - sandboxBaseUrl: string, -): string { +function rewriteLocationHeader(location: string, taskId: string, port: string, sandboxBaseUrl: string): string { try { const loc = new URL(location, sandboxBaseUrl) const previewPrefix = trwPreviewPrefix(port) diff --git a/packages/server/src/sandbox/preview-ws-proxy.ts b/packages/server/src/sandbox/preview-ws-proxy.ts index e561a6a..d91c0e0 100644 --- a/packages/server/src/sandbox/preview-ws-proxy.ts +++ b/packages/server/src/sandbox/preview-ws-proxy.ts @@ -71,9 +71,7 @@ export function attachPreviewWebSocketProxy(server: Server): void { proxyReq.on('upgrade', (res, upstreamSocket, upgradeHead) => { const statusLine = `HTTP/1.1 ${res.statusCode ?? 101} ${res.statusMessage ?? 'Switching Protocols'}` const headerLines = Object.entries(res.headers) - .flatMap(([key, values]) => - (Array.isArray(values) ? values : [values]).map((v) => `${key}: ${v}`), - ) + .flatMap(([key, values]) => (Array.isArray(values) ? values : [values]).map((v) => `${key}: ${v}`)) .join('\r\n') clientSocket.write(`${statusLine}\r\n${headerLines}\r\n\r\n`) if (upgradeHead.length > 0) upstreamSocket.write(upgradeHead) diff --git a/packages/server/src/sandbox/provider/stateful-provider.ts b/packages/server/src/sandbox/provider/stateful-provider.ts index f73484c..9b5b992 100644 --- a/packages/server/src/sandbox/provider/stateful-provider.ts +++ b/packages/server/src/sandbox/provider/stateful-provider.ts @@ -273,10 +273,7 @@ async function ensureSingleEnvInstance( const active = discover.filter((it) => ['RUNNING', 'PAUSED', 'RESUME_FAILED'].includes(it.status)) const primary = pickPrimaryInstance(active) if (!primary) { - const sandboxId = await startStatefulInstanceWithWarmup( - () => startStatefulInstance(cfg, toolId), - onProgress, - ) + const sandboxId = await startStatefulInstanceWithWarmup(() => startStatefulInstance(cfg, toolId), onProgress) return { sandboxId, created: true } } @@ -314,10 +311,7 @@ async function ensureTaskInstance( } } - const sandboxId = await startStatefulInstanceWithWarmup( - () => startStatefulInstance(cfg, toolId), - onProgress, - ) + const sandboxId = await startStatefulInstanceWithWarmup(() => startStatefulInstance(cfg, toolId), onProgress) return { sandboxId, created: true } } diff --git a/packages/server/src/sandbox/stateful/e2b-native-client.ts b/packages/server/src/sandbox/stateful/e2b-native-client.ts index f7f79b3..6375051 100644 --- a/packages/server/src/sandbox/stateful/e2b-native-client.ts +++ b/packages/server/src/sandbox/stateful/e2b-native-client.ts @@ -90,7 +90,11 @@ export async function statefulReadBinaryFile(inst: SandboxInstance, filePath: st } } -export async function statefulWriteTextFile(inst: SandboxInstance, filePath: string, content: string): Promise<boolean> { +export async function statefulWriteTextFile( + inst: SandboxInstance, + filePath: string, + content: string, +): Promise<boolean> { const sdk = await createStatefulNativeE2bClient(inst) const normalized = resolveStatefulFilePath(filePath) try { diff --git a/packages/server/src/sandbox/stateful/gateway.ts b/packages/server/src/sandbox/stateful/gateway.ts index 6427e09..531677a 100644 --- a/packages/server/src/sandbox/stateful/gateway.ts +++ b/packages/server/src/sandbox/stateful/gateway.ts @@ -86,10 +86,7 @@ export function buildTrwPreviewGatewayTarget(args: { }) } -export async function gatewayFetch( - target: StatefulGatewayTarget, - init?: RequestInit, -): Promise<Response> { +export async function gatewayFetch(target: StatefulGatewayTarget, init?: RequestInit): Promise<Response> { return fetch(target.url, { ...init, headers: { diff --git a/packages/server/src/sandbox/task-sandbox.ts b/packages/server/src/sandbox/task-sandbox.ts index 9475fd4..9c36979 100644 --- a/packages/server/src/sandbox/task-sandbox.ts +++ b/packages/server/src/sandbox/task-sandbox.ts @@ -8,10 +8,7 @@ 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' +import { statefulReadBinaryFile, statefulReadTextFile } from './stateful/e2b-native-client.js' export interface CommandResult { success: boolean @@ -129,10 +126,7 @@ export async function readFileFromSandbox( } /** Download file bytes via TRW POST /api/tools/files_download (production TRW has no /e2b-compatible). */ -export async function downloadFileFromSandbox( - sandbox: SandboxInstance, - filePath: string, -): Promise<Uint8Array | null> { +export async function downloadFileFromSandbox(sandbox: SandboxInstance, filePath: string): Promise<Uint8Array | null> { try { const res = await sandbox.request('/api/tools/files_download', { method: 'POST', @@ -148,7 +142,11 @@ export async function downloadFileFromSandbox( } } -export async function writeFileToSandbox(sandbox: SandboxInstance, filePath: string, content: string): Promise<boolean> { +export async function writeFileToSandbox( + sandbox: SandboxInstance, + filePath: string, + content: string, +): Promise<boolean> { try { const res = await sandbox.request('/api/tools/write', { method: 'POST', diff --git a/packages/server/src/sandbox/ws-auth.ts b/packages/server/src/sandbox/ws-auth.ts index 6818371..2e9afee 100644 --- a/packages/server/src/sandbox/ws-auth.ts +++ b/packages/server/src/sandbox/ws-auth.ts @@ -39,10 +39,7 @@ async function resolveUserEnvId(userId: string, taskId: string): Promise<string return null } -export async function resolveSandboxForTaskWs( - req: IncomingMessage, - taskId: string, -): Promise<SandboxInstance | null> { +export async function resolveSandboxForTaskWs(req: IncomingMessage, taskId: string): Promise<SandboxInstance | null> { const rawCookie = parseCookie(req.headers.cookie, SESSION_COOKIE_NAME) if (!rawCookie) return null From 90a5e417d5d1842ca114d5fae2af67d0b8b8b123 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Thu, 21 May 2026 17:52:16 +0800 Subject: [PATCH 08/29] docs(stateful): align docs and init with AGS/TRW sandbox Replace stale SCF/Scope API narrative in architecture, README, and setup; emit STATEFUL_* in init.mjs. Restore main task-sidebar delete UX and drop unrelated LoginPage registration copy from the stateful branch. Co-authored-by: Cursor <cursoragent@cursor.com> --- CHANGELOG.md | 6 ++ README.md | 14 +-- docs/architecture.md | 92 +++++++++---------- docs/crontask-cloudfunction-plan.md | 2 +- docs/scf-session-sharing.md | 2 + docs/setup.md | 17 ++-- .../src/middleware/mcp/cloudbase/README.md | 2 +- .../mcp/cloudbase/getDeployJobStatus.ts | 2 +- packages/web/src/components/task-sidebar.tsx | 55 +++++++++-- packages/web/src/pages/LoginPage.tsx | 3 +- scripts/init.mjs | 12 +-- 11 files changed, 128 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3ddfc0..ae22a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ All notable changes to this project will be documented in this file. ### Added +- **AGS Stateful Sandbox**:`StatefulSandboxProvider` + `ensureStatefulTool` + TRW 数据面;移除 SCF `scf-sandbox-manager` / `sandbox-mcp-proxy`;预览经 OVC → gateway → TRW `/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/README.md b/README.md index aa965c7..7acf71b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE) [![pnpm](https://img.shields.io/badge/maintained%20with-pnpm-cc00ff.svg)](https://pnpm.io/) -[![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org/) +[![Node](https://img.shields.io/badge/node-22.x-brightgreen.svg)](https://nodejs.org/) ## 延伸阅读 @@ -20,9 +20,9 @@ | **多 Agent 运行时** | CodeBuddy / OpenCode / MiMo 三个 runtime 并行可选;per-agent 独立模型列表;切换时自动校验 selectedModel | | **三级环境隔离** | `shared`(共用)/ `isolated`(用户独立)/ `task`(任务独立 + 独立 CAM 子账号)三种模式,admin 后台动态切换,无需重启 | | **环境池** | 预创建 CloudBase 环境 + CAM + Policy,获取延迟从分钟级降至毫秒级;池空自动回退实时创建;多 Pod CAS 安全 | -| **编码模式沙箱** | 任务启动自动冷启动 SCF 容器;PTY 执行 bash;vite dev server 端口动态分配;预览进度细分(镜像拉取 → 容器就绪 → 工作区初始化) | -| **Preview Bridge** | 内嵌 Browser 工具栏(地址栏 / 刷新 / 前进后退 / 设备切换);postMessage 协议;HMR 热更新;预览错误自动修复 | -| **子工作区隔离** | 同一 session 内多个隔离 Scope,独立 vite dev server,端口 5173-5199 动态分配;`X-Scope-Id` 头控制 | +| **编码模式沙箱** | AGS Stateful Sandbox + TRW;按 envId 单实例复用;`ensureStatefulTool` + 镜像预热;工作区 `/home/user`;预览经 gateway → TRW `/preview/5173/` | +| **Preview Bridge** | 内嵌 Browser 工具栏(地址栏 / 刷新 / 前进后退 / 设备切换);OVC 反向代理至 TRW;HMR;预览错误自动修复 | +| **Web 终端** | ttyd 经 TRW `/preview/7681/`,OVC 代理为 `/api/tasks/:id/preview/7681/` | | **CloudBase MCP** | 内置 50+ CloudBase 工具(DB / Storage / Functions / 域名 / 安全规则);koa 风格 middleware 框架;stdio + HTTP 双模式 | | **Human-in-Loop** | ToolConfirm(四值权限:allow / allow_always / deny / reject_and_exit_plan);AskUserQuestion 内联表单;消息流内渲染,不打断上下文 | | **Plan 模式** | 写操作拦截;PlanModeCard 三按钮(允许执行 / 继续完善 / 拒绝退出);`planModeAtomFamily` 跨组件状态共享 | @@ -80,7 +80,7 @@ ├── docs/ │ ├── setup.md # setup 详解与排障 │ ├── architecture.md # 系统架构文档 -│ └── scf-session-sharing.md # SCF Session 共享设计 +│ └── scf-session-sharing.md # (历史)SCF Session 共享,stateful 分支已废弃 ├── packages/ │ ├── web/ # React 19 + Vite 前端 │ ├── server/ # Hono 后端:Auth、Agent 编排、Sandbox 管理 @@ -283,7 +283,7 @@ CLOUDBASE_API_KEY=eyJhbGciOiJS.xxxxxxxx | 后端 | Hono, Node.js, Drizzle ORM | | 数据库 | CloudBase DB(主),SQLite(本地回退) | | AI | `@tencent-ai/agent-sdk` (CodeBuddy), OpenCode ACP, MiMo | -| Sandbox | CloudBase SCF, TCR 容器镜像 | +| Sandbox | CloudBase AGS Stateful Sandbox, TRW, TCR 镜像 | | 认证 | JWE session, bcrypt, Arctic (OAuth) | | 持久化 | CloudBase DB, 本地 .jsonl, Git archive | | 协议 | ACP (JSON-RPC 2.0 + SSE), MCP (Model Context Protocol) | @@ -300,7 +300,7 @@ CLOUDBASE_API_KEY=eyJhbGciOiJS.xxxxxxxx | -------- | -------------- | ------------------------------------------ | | 架构 | Next.js 全栈 | Monorepo 前后端分离(React + Vite / Hono) | | 部署 | Vercel | 腾讯云 CloudBase | -| Sandbox | Vercel Sandbox | CloudBase SCF | +| Sandbox | Vercel Sandbox | CloudBase AGS Stateful Sandbox (TRW) | | Agent | 单一 runtime | CodeBuddy / OpenCode / MiMo 多 runtime | | 环境隔离 | 无 | shared / isolated / task 三级 | diff --git a/docs/architecture.md b/docs/architecture.md index 0a6c872..1c1efa9 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 在 **AGS Stateful Sandbox**(TRW 数据面 + gateway 路由)中执行代码操作,结果通过 SSE 流式返回并持久化到 CloudBase 数据库。 ```mermaid graph TB @@ -27,7 +27,7 @@ graph TB subgraph Infra["CloudBase Infrastructure"] DB[("CloudBase DB")] - SCF["SCF Sandbox"] + AGS["AGS Stateful Sandbox"] 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 --> AGS + TaskSvc --> AGS RuntimeMgr --> Persist --> DB - SCF --> CNB - SCF --> TCR + AGS --> CNB + AGS --> TCR TaskSvc --> Storage Auth --> EnvPool --> DB ``` @@ -251,67 +251,63 @@ Agent 调用子 Agent 时,`parent_tool_use_id` 从 SDK 顶层透传至前端 ` ## Sandbox Module -Sandbox 模块为每个任务 / 会话提供隔离的执行环境。 +Sandbox 模块为每个 **envId** 提供 Stateful 执行环境(AGS 控制面 + TRW 数据面),任务共享同一实例上的 `/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 --> AGS["AGS StartSandboxInstance"] + AGS --> TRW["TRW :9000"] + TRW --> FS["File System /home/user"] + TRW --> Bash["Bash / PTY"] + TRW --> Preview["/preview/5173 / 7681"] + TRW --> Git["Git Archive"] + Provider --> McpClient["stateful-mcp-client"] + McpClient --> CloudBase["CloudBase Tools"] + McpClient --> Deploy["Deployment Tools"] + Web["Web UI"] --> OvcProxy["OVC preview proxy"] + OvcProxy --> Gateway["tcloudbasegateway.com"] + Gateway --> TRW ``` -### 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)` 为环境创建或复用 AGS Sandbox Tool(`sdt-xxx`),镜像来自 `STATEFUL_SANDBOX_IMAGE` / TCR +2. **Start / Reuse Instance** — `StatefulSandboxProvider` 按 envId 单实例:running 复用、paused 恢复、缺失则 `StartSandboxInstance`;镜像更新后 `stateful-tool-warmup` 轮询预热 +3. **Data Plane** — `TCB_API_KEY` + `E2b-Sandbox-Id` / `E2b-Sandbox-Port: 9000` 经 gateway 访问 TRW +4. **Init Workspace** — `PUT /api/workspace/env` 注入凭证,`POST /api/workspace/init` 初始化 `/home/user` +5. **Execute** — Agent 工具经 TRW `/api/tools/*`;CloudBase MCP 由 server 侧 `stateful-mcp-client` 转发 +6. **Archive** — 任务结束时 Git 归档工作区 -### Workspace Provisioning API - -Sandbox 内部使用语义化 Provisioning API 管理工作区层级: +### TRW 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 路由到 TRW(OVC 对浏览器暴露 `/api/tasks/:id/preview/:port/*` 反向代理): -| Capability | Endpoint | Description | +| Capability | TRW path | Description | | --- | --- | --- | -| File System | `/api/tools/read`, `write`, `files_upload`, `files_download` | 文件读写 | +| File System | `/api/tools/read`, `write`, … | 文件读写 | | Bash | `/api/tools/bash` | Shell 命令执行 | | Web Terminal | `/preview/7681/` (ttyd) | 浏览器终端 | -| Vite preview | `/preview/5173/` | 开发服务器(vibecoding lazy ensure) | -| Git Push | `POST /api/extend/git_push` | 将工作区变更推送到远端(`ENABLE_GIT_ARCHIVE`) | -| MCP Server | In-memory transport | CloudBase 工具和部署工具 | -| Health | `/health` | 容器健康检查 | -| Scope Info | `/api/scope/info` | 子工作区路径和 vite 状态 | - -### Scope API(子工作区隔离) - -同一 session 内支持多个相互隔离的子工作区: +| Vite preview | `/preview/5173/` | 开发服务器(默认端口 5173) | +| Git Push | `POST /api/extend/git_push` | 工作区推送到远端(`ENABLE_GIT_ARCHIVE`) | +| 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),与 TRW 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/`: @@ -415,7 +411,7 @@ sequenceDiagram participant Web participant Server participant Runtime as ACP Runtime - participant Sandbox as SCF Sandbox + participant Sandbox as AGS + TRW participant DB as CloudBase DB participant Git as Git Archive @@ -425,7 +421,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 @@ -457,7 +453,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 | CloudBase AGS Stateful Sandbox, TRW, 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) | @@ -688,6 +684,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/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..90df671 100644 --- a/docs/scf-session-sharing.md +++ b/docs/scf-session-sharing.md @@ -1,5 +1,7 @@ # SCF 沙箱 Session 共享改造方案 +> **已废弃**:`feature/stateful-infra` 已迁移至 **CloudBase AGS Stateful Sandbox + TRW**(`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 db64c3f..29fe1af 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -93,7 +93,7 @@ flowchart TD - CloudBase 相关配置(`TCB_ENV_ID`、`TCB_SECRET_ID`、`TCB_SECRET_KEY`) - CodeBuddy 认证配置 - 数据库提供方配置 -- SCF Sandbox / TCR 配置 +- Stateful Sandbox(AGS + TRW)/ TCR 镜像配置 - 可选的 GitHub OAuth、代理配置 初始化脚本会优先把 CloudBase 和服务端相关配置写入这里。 @@ -120,14 +120,17 @@ flowchart TD | `CODEBUDDY_CLIENT_SECRET` | 二选一 | OAuth 模式下使用 | | `CODEBUDDY_OAUTH_ENDPOINT` | 否 | OAuth Token 端点,默认使用国内地址 | -### Sandbox / TCR +### Stateful Sandbox / TCR | 变量 | 必需 | 说明 | | --- | --- | --- | -| `SCF_SANDBOX_IMAGE_TYPE` | 否 | 镜像类型,默认 `personal` | -| `SCF_SANDBOX_IMAGE_URI` | 是 | SCF Sandbox 所使用的镜像 URI | -| `SCF_SANDBOX_IMAGE_PORT` | 否 | 容器暴露端口,默认 `9000` | -| `TCR_IMAGE` | 建议 | `setup-tcr` 成功后会写入,用于 sandbox 镜像配置 | +| `TCB_API_KEY` | 是 | gateway 数据面 Bearer(与 `TCB_ENV_ID` 配套) | +| `STATEFUL_TOOL_ID` | 建议 | 已有 vibecoding SDT 时填写,跳过 `CreateSandboxTool` | +| `STATEFUL_SANDBOX_IMAGE` | 创建 tool 时必需 | AGS 工具镜像 URI(通常与 `TCR_IMAGE` 相同) | +| `STATEFUL_GATEWAY_URL` | 否 | 默认 `https://{TCB_ENV_ID}.api.tcloudbasegateway.com/v1/sandbox/-` | +| `STATEFUL_SANDBOX_ID` | 否 | 调试时固定实例 ID | +| `STATEFUL_TOOL_WARMUP_POLL_MS` / `STATEFUL_TOOL_WARMUP_POLL_MAX` | 否 | 镜像更新后预热轮询(默认 10s × 6) | +| `TCR_IMAGE` | 建议 | `setup-tcr` 写入;供 `STATEFUL_SANDBOX_IMAGE` 引用 | ## 用户环境模式 @@ -325,4 +328,4 @@ pnpm opencode:setup - [根目录 README](../README.md) - [系统架构文档](./architecture.md) -- [SCF Session 共享方案](./scf-session-sharing.md) +- [SCF Session 共享方案](./scf-session-sharing.md)(**已废弃**,stateful 分支请以上表为准) diff --git a/packages/server/src/middleware/mcp/cloudbase/README.md b/packages/server/src/middleware/mcp/cloudbase/README.md index 02b9de6..c6fe5e9 100644 --- a/packages/server/src/middleware/mcp/cloudbase/README.md +++ b/packages/server/src/middleware/mcp/cloudbase/README.md @@ -187,7 +187,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(经 TRW 数据面) # 两条路径都用 lib/cloudbase-mcp-utils + middleware/mcp/cloudbase ``` diff --git a/packages/server/src/middleware/mcp/cloudbase/getDeployJobStatus.ts b/packages/server/src/middleware/mcp/cloudbase/getDeployJobStatus.ts index ee7ded0..82cdad4 100644 --- a/packages/server/src/middleware/mcp/cloudbase/getDeployJobStatus.ts +++ b/packages/server/src/middleware/mcp/cloudbase/getDeployJobStatus.ts @@ -3,7 +3,7 @@ * * 查询 publishMiniprogram 异步返回的 jobId。 * - * 历史背景:原本在 sandbox-mcp-proxy.ts 中硬编码(line 507-527 DEPLOY_STATUS_*)。 + * 历史背景:原本在已删除的 sandbox-mcp-proxy.ts 中硬编码(DEPLOY_STATUS_*)。 */ import type { McpPolicy } from './_index.js' diff --git a/packages/web/src/components/task-sidebar.tsx b/packages/web/src/components/task-sidebar.tsx index a0881d4..849a3fc 100644 --- a/packages/web/src/components/task-sidebar.tsx +++ b/packages/web/src/components/task-sidebar.tsx @@ -1,7 +1,7 @@ import type { Task } from '@coder/shared' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { AlertCircle, Plus, Trash2, GitBranch, Loader2, Search, X, Smartphone, Clock } from 'lucide-react' +import { AlertCircle, Plus, Trash2, GitBranch, Loader2, Search, X, MoreVertical, Smartphone, Clock } from 'lucide-react' import { cn } from '@/lib/utils' import { Link, useLocation } from 'react-router' import { Claude, CodeBuddy, Codex, Copilot, Cursor, Gemini, OpenCode } from '@/components/logos' @@ -17,6 +17,7 @@ import { } from '@/components/ui/alert-dialog' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { useState, useMemo, useEffect, useRef, useCallback } from 'react' import { toast } from 'sonner' import { useTasks } from '@/components/app-layout' @@ -299,6 +300,27 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { fetchSearchResults, ]) + const handleDeleteSingleTask = async (taskId: string, e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + try { + const response = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE', credentials: 'include' }) + if (response.ok) { + toast.success('Task deleted') + refreshTasks() + } else { + const data = await response.json() + // 后端 409 时 failed 数组含每步的 step / message / code / requestId + const detail = Array.isArray(data?.failed) + ? data.failed.map((f: any) => `[${f.step}] ${f.message || f.code || 'failed'}`).join(';') + : data?.detail || '' + toast.error(detail ? `${data.error || '删除失败'}:${detail}` : data.error || 'Failed to delete task') + } + } catch { + toast.error('Failed to delete task') + } + } + const handleDeleteTasks = async () => { if (!deleteCompleted && !deleteFailed && !deleteStopped) { toast.error('Please select at least one task type to delete') @@ -309,21 +331,20 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { try { const actions = [] if (deleteCompleted) actions.push('completed') - if (deleteFailed) actions.push('error') + if (deleteFailed) actions.push('failed') if (deleteStopped) actions.push('stopped') const response = await fetch(`/api/tasks?action=${actions.join(',')}`, { method: 'DELETE', - credentials: 'include', }) if (response.ok) { - const result = await response.json().catch(() => ({})) - toast.success(result.message || 'Tasks deleted') + const result = await response.json() + toast.success(result.message) await refreshTasks() setShowDeleteDialog(false) } else { - const error = await response.json().catch(() => ({})) + const error = await response.json() toast.error(error.error || 'Failed to delete tasks') } } catch (error) { @@ -541,6 +562,28 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { {task.status === 'stopped' && ( <AlertCircle className="h-3 w-3 text-orange-500 flex-shrink-0" /> )} + <DropdownMenu> + <DropdownMenuTrigger + asChild + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + }} + > + <button className="h-5 w-5 p-0 flex items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-muted-foreground/20 transition-opacity"> + <MoreVertical className="h-3 w-3" /> + </button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-36"> + <DropdownMenuItem + className="text-xs text-destructive cursor-pointer" + onClick={(e) => handleDeleteSingleTask(task.id, e)} + > + <Trash2 className="h-3 w-3 mr-1.5" /> + Delete + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> </div> </div> {task.repoUrl && ( diff --git a/packages/web/src/pages/LoginPage.tsx b/packages/web/src/pages/LoginPage.tsx index 91aa2dd..40517ef 100644 --- a/packages/web/src/pages/LoginPage.tsx +++ b/packages/web/src/pages/LoginPage.tsx @@ -35,8 +35,7 @@ export function LoginPage() { setSession({ user: data.user, envId: data.envId }) navigate('/') } catch (err) { - const message = err instanceof Error ? err.message : 'Failed' - setError(message === 'Registration failed' ? '注册失败,请查看服务端日志或联系管理员' : message) + setError(err instanceof Error ? err.message : 'Failed') } finally { setIsLoading(false) } diff --git a/scripts/init.mjs b/scripts/init.mjs index 948cf64..b3207ce 100644 --- a/scripts/init.mjs +++ b/scripts/init.mjs @@ -951,13 +951,13 @@ GIT_ARCHIVE_REPO=${getPreserved('GIT_ARCHIVE_REPO')} GIT_ARCHIVE_USER=${getPreserved('GIT_ARCHIVE_USER')} GIT_ARCHIVE_TOKEN=${getPreserved('GIT_ARCHIVE_TOKEN')} -# ==================== SCF Sandbox ==================== +# ==================== Stateful Sandbox (AGS + TRW) ==================== -SCF_SANDBOX_IMAGE_TYPE=${get('SCF_SANDBOX_IMAGE_TYPE', 'personal')} -SCF_SANDBOX_IMAGE_URI=${get('TCR_IMAGE')} -SCF_SANDBOX_IMAGE_ACCELERATE=${get('SCF_SANDBOX_IMAGE_ACCELERATE', 'false')} -SCF_SANDBOX_IMAGE_PORT=${get('SCF_SANDBOX_IMAGE_PORT', '9000')} -SCF_SANDBOX_TEST_URL=${get('SCF_SANDBOX_TEST_URL')} +# TCB_API_KEY: set in CloudBase console or paste after init +STATEFUL_SANDBOX_IMAGE=${get('STATEFUL_SANDBOX_IMAGE', get('TCR_IMAGE'))} +# STATEFUL_TOOL_ID= # existing SDT; skips CreateSandboxTool +# STATEFUL_GATEWAY_URL= +# STATEFUL_SANDBOX_ID= # debug: pin instance # ==================== GitHub OAuth (Optional) ==================== From cd610e12c8ff3c7454cb9cfebf85f1fdb73f074d Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Thu, 21 May 2026 19:12:57 +0800 Subject: [PATCH 09/29] feat(stateful): sandbox progress, public TCR default, and docs - Resolve Tool by ovc-{envId} without STATEFUL_TOOL_ID; bind/create progress phases - Distinguish shared vs isolated instance reuse in provider and status UI - Default STATEFUL_SANDBOX_IMAGE from team public TCR; optional tenant TCR via setup:tcr - Document sandbox infra (not product codename), provision vs instance mode - Dockerfile git for install-skills; lockfile sync; setup-tcr syncs server .env image Co-authored-by: Cursor <cursoragent@cursor.com> --- .env.example | 22 ++- CHANGELOG.md | 2 +- Dockerfile | 4 + README.md | 154 ++++++++++++++---- docs/architecture.md | 26 +-- docs/scf-session-sharing.md | 2 +- docs/setup.md | 25 ++- .../src/sandbox/ensure-stateful-tool.ts | 85 +++++++++- .../src/sandbox/provider/stateful-provider.ts | 42 ++++- .../src/sandbox/stateful-tool-warmup.ts | 16 +- .../src/sandbox/stateful-vibecoding-image.ts | 91 +++++++++++ .../chat/agent-status-indicator.tsx | 16 +- .../web/src/pages/admin/settings-page.tsx | 6 +- pnpm-lock.yaml | 20 --- scripts/setup-tcr.mjs | 34 ++++ 15 files changed, 439 insertions(+), 106 deletions(-) create mode 100644 packages/server/src/sandbox/stateful-vibecoding-image.ts diff --git a/.env.example b/.env.example index 206546b..adde478 100644 --- a/.env.example +++ b/.env.example @@ -27,9 +27,9 @@ TCB_SECRET_KEY= # 区域,默认 ap-shanghai # TCB_REGION=ap-shanghai -# 用户环境模式:shared(默认)或 isolated -# TCB_PROVISION_MODE=shared -# SANDBOX_INSTANCE_MODE=shared # shared = one AGS instance per env; isolated = one per task +# CloudBase 用户环境(与下面 SANDBOX_INSTANCE_MODE 不是一回事) +# TCB_PROVISION_MODE=shared # shared | isolated | task — 见 README「两套共享/隔离」 +# SANDBOX_INSTANCE_MODE=shared # shared | isolated — 沙箱实例是否跨任务复用(packages/server/.env) # ==================== CodeBuddy Auth ==================== @@ -53,8 +53,12 @@ MAX_SANDBOX_DURATION=300 # Copy server secrets to packages/server/.env — see .plans/stateful-env-checklist.md # TCB_API_KEY= # gateway JWT (sandbox apikey create) -# STATEFUL_TOOL_ID= # existing vibecoding SDT; skips CreateTool -# STATEFUL_SANDBOX_IMAGE= # required only when creating a new tool +# STATEFUL_TOOL_ID= # DEBUG ONLY — skips DB/CreateTool; normal flow uses ToolName ovc-{TCB_ENV_ID} +# STATEFUL_SANDBOX_IMAGE= # 首次 CreateSandboxTool;不配则用代码公开 TCR 默认或 TCR_IMAGE +# 公开默认见 packages/server/src/sandbox/stateful-vibecoding-image.ts(团队公开 ns,非你的密钥) +# 自管镜像示例: ccr.ccs.tencentyun.com/<your-namespace>/tcb-sandbox-ags:<YYMMDD-HHMM-xxxx-vibecoding> +# STATEFUL_SANDBOX_IMAGE_TAG= # URI 无 :tag 时补全(默认与代码常量一致) +# OVC_PUBLIC_TCR_REPO= # 仅当公开 ns 下仓库名不是 tcb-sandbox-public-cbe88d 时覆盖 # STATEFUL_GATEWAY_URL= # optional; default https://{TCB_ENV_ID}.api.tcloudbasegateway.com/v1/sandbox/- # STATEFUL_SANDBOX_ID= # optional: pin a running instance (debug) # STATEFUL_MINIPROGRAM_FEATURE=true # when TRW exposes /api/jobs/miniprogram-deploy @@ -65,10 +69,10 @@ MAX_SANDBOX_DURATION=300 # ==================== TCR ==================== -# TCR 容器镜像配置(由 pnpm setup:tcr 自动写入) -# TCR_NAMESPACE= -# TCR_PASSWORD= -# TCR_IMAGE= +# TCR 容器镜像(pnpm setup:tcr → .env.local;可同步到 packages/server/.env 作 STATEFUL_SANDBOX_IMAGE) +# TCR_NAMESPACE= # 你的 ccr 命名空间,非公开 ns +# TCR_PASSWORD= # 勿提交 git +# TCR_IMAGE= # ccr.ccs.tencentyun.com/<namespace>/tcb-sandbox-ags:<tag> # ==================== GitHub OAuth (Optional) ==================== diff --git a/CHANGELOG.md b/CHANGELOG.md index ae22a84..66a2f60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. ### Added -- **AGS Stateful Sandbox**:`StatefulSandboxProvider` + `ensureStatefulTool` + TRW 数据面;移除 SCF `scf-sandbox-manager` / `sandbox-mcp-proxy`;预览经 OVC → gateway → TRW `/preview/5173/`;终端 ttyd `/preview/7681/`;镜像更新后 `stateful-tool-warmup` 轮询 +- **沙箱 infra(Stateful + TRW)**:`StatefulSandboxProvider` + `ensureStatefulTool` + TRW 数据面;移除 SCF `scf-sandbox-manager` / `sandbox-mcp-proxy`;预览经 OVC → gateway → TRW `/preview/5173/`;终端 ttyd `/preview/7681/`;镜像更新后 `stateful-tool-warmup` 轮询 ### Changed diff --git a/Dockerfile b/Dockerfile index 1a7e769..d23c402 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.md b/README.md index 7acf71b..e9a99cf 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ | **多 Agent 运行时** | CodeBuddy / OpenCode / MiMo 三个 runtime 并行可选;per-agent 独立模型列表;切换时自动校验 selectedModel | | **三级环境隔离** | `shared`(共用)/ `isolated`(用户独立)/ `task`(任务独立 + 独立 CAM 子账号)三种模式,admin 后台动态切换,无需重启 | | **环境池** | 预创建 CloudBase 环境 + CAM + Policy,获取延迟从分钟级降至毫秒级;池空自动回退实时创建;多 Pod CAS 安全 | -| **编码模式沙箱** | AGS Stateful Sandbox + TRW;按 envId 单实例复用;`ensureStatefulTool` + 镜像预热;工作区 `/home/user`;预览经 gateway → TRW `/preview/5173/` | +| **编码模式沙箱** | 沙箱 infra(Stateful + TRW);按 envId 单实例复用;`ensureStatefulTool` + 镜像预热;工作区 `/home/user`;预览经 gateway → TRW `/preview/5173/` | | **Preview Bridge** | 内嵌 Browser 工具栏(地址栏 / 刷新 / 前进后退 / 设备切换);OVC 反向代理至 TRW;HMR;预览错误自动修复 | | **Web 终端** | ttyd 经 TRW `/preview/7681/`,OVC 代理为 `/api/tasks/:id/preview/7681/` | | **CloudBase MCP** | 内置 50+ CloudBase 工具(DB / Storage / Functions / 域名 / 安全规则);koa 风格 middleware 框架;stdio + HTTP 双模式 | @@ -127,13 +127,25 @@ pnpm dev:web # 仅启动前端 pnpm dev:server # 仅启动后端 ``` -## 生产 +## 生产(本机) ```bash pnpm build # 构建所有包 pnpm start # 启动生产服务(端口 3001,同时服务 API 和静态文件) ``` +## 云托管(CloudRun) + +OVC 以**容器**部署到 CloudBase 云托管:根目录 `Dockerfile` 构建前后端一体镜像,监听 **80**。 + +```bash +tcb env use <TCB_ENV_ID> +tcb cloudrun deploy -e <TCB_ENV_ID> -s <服务名> --port 80 --source . --force +``` + +构建日志与访问地址在控制台「云托管 → 服务详情 → 部署」。环境变量在**同一服务的「服务设置 → 环境变量」**配置(见下节),不要依赖把 `packages/server/.env` 打进镜像(`.dockerignore` 已排除 `.env*`)。 + +更细的初始化与排障见 [docs/setup.md](docs/setup.md)。 ## 常用命令 @@ -160,37 +172,113 @@ 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.local` | 根目录;`init.sh` 写入 `JWE_SECRET`、`ENCRYPTION_KEY`、`NEXT_PUBLIC_AUTH_PROVIDERS` 等 | +| `packages/server/.env` | **Server 运行时**(本地 `pnpm dev` / `pnpm start` 读这里;云托管在控制台填同等变量) | + +更多字段说明见 [docs/setup.md](docs/setup.md)。 + +### 沙箱 infra · Tool 模板(`STATEFUL_TOOL_ID` 要不要配?) + +**结论:日常开发/上线都不要配 `STATEFUL_TOOL_ID`。** 它只用于调试或运维脚本(`describe-stateful-tool.ts` 等)。 + +代码里 **Tool 名称是固定的**,由支撑环境 ID 推导: + +```text +ToolName = ovc-{TCB_ENV_ID} # 非法字符会替换为 -,最长 48 字符 +``` + +例如 `TCB_ENV_ID=<your-support-env-id>` → Tool 名 `ovc-<your-support-env-id>`(非法字符会替换为 `-`)。 + +**正常解析顺序**(`ensureStatefulTool`): + +1. ~~`STATEFUL_TOOL_ID` 环境变量~~ — **仅调试**,会跳过 DB 与创建逻辑,不应写入 `.env` +2. **数据库** — `shared` 模式:`settings.stateful_tool_id`;`isolated` / `task`:`user_resources.statefulToolId` +3. **按名绑定** — 沙箱控制面 `DescribeSandboxToolList`,匹配 `ToolName`,写回 DB +4. **首次创建** — 尚无记录时 `CreateSandboxTool`,使用上述 `ToolName`,再把返回的 `sdt-xxx` 写入 DB + +因此:本地第一次跑任务时会创建 Tool 并落库;换机器只要连同一套 CloudBase DB,就会复用同一个 `sdt-xxx`,**不必**在 `.env` 里手写 ToolId。若 DB 被清空但平台上仍有同名 Tool,会先按 `ToolName` 查询再写回 DB。 + +### 两套「共享 / 隔离」别混 + +| 维度 | 环境变量 / 配置 | 管什么 | `shared` | `isolated` | +| --- | --- | --- | --- | --- | +| **CloudBase 用户环境** | `TCB_PROVISION_MODE`(init 或 `/admin/settings`) | 用户是否共用**支撑环境**、是否预建 `user_resources` | 全员共用 `TCB_ENV_ID` | 每用户独立 env + CAM | +| **沙箱实例** | `SANDBOX_INSTANCE_MODE`(`packages/server/.env` 或 DB `sandbox_instance_mode`) | **运行时容器**是否跨任务复用 | 同一支撑 env 下多任务共用一个实例 | 每任务独立实例(复用该任务的 `sandboxId`) | + +- 本地试 OVC 沙箱行为:改 **`SANDBOX_INSTANCE_MODE`** 即可(`shared` / `isolated`),与是否多租户无关。 +- **`TCB_PROVISION_MODE=task`** 时新建任务默认实例模式倾向 `isolated`(见 `sandbox-config.ts`),仍可用 Admin 或 env 覆盖。 +- 优先级(实例模式):DB `sandbox_instance_mode` → env `SANDBOX_INSTANCE_MODE` → 内置默认 `shared`。 +- UI 进度文案:`shared` 会出现「复用环境沙箱(多任务共享)」;`isolated` 为「复用任务沙箱」/「为当前任务启动沙箱实例」。 + +### 沙箱镜像(`STATEFUL_SANDBOX_IMAGE`) + +沙箱 infra 首次创建 Tool(`CreateSandboxTool`)需要 **腾讯云 TCR 个人版** 完整 URI(`ccr.ccs.tencentyun.com/<namespace>/<repo>:<tag>`),不能填 Docker Hub / GHCR 直链。 + +**解析顺序**(`resolveStatefulSandboxImage`,见 `stateful-vibecoding-image.ts`): + +1. `packages/server/.env` 的 **`STATEFUL_SANDBOX_IMAGE`** +2. 同文件或根目录 `.env.local` 的 **`TCR_IMAGE`**(`pnpm setup:tcr` 写入) +3. **代码内置默认**(团队公开 TCR,开箱首次建 Tool): + `ccr.ccs.tencentyun.com/tcb-sandbox-public-cbe88d/tcb-sandbox-public-cbe88d:<tag>` + 默认 tag 与常量 `VIBECODING_PUBLIC_TCR_DEFAULT_TAG` 同步(当前为带时间的 `…-vibecoding` 后缀,非 `latest`)。 + +**用你自己的 TCR 镜像(推荐自部署 / 定制 TRW 时)** + +1. 在 [腾讯云 TCR 个人版](https://console.cloud.tencent.com/tcr) 创建命名空间,`docker login ccr.ccs.tencentyun.com`(用户名一般为账号 UIN)。 +2. 构建 TRW vibecoding 镜像后推送,tag 建议一条龙格式:`YYMMDD-HHMM-<随机>-vibecoding`(见仓库外 `code_sandbox/一条龙.md` § Tag & Push)。 +3. 写入 **`packages/server/.env`**(云托管写控制台同等变量): + +```bash +# 二选一即可;显式优先 +STATEFUL_SANDBOX_IMAGE=ccr.ccs.tencentyun.com/<your-namespace>/tcb-sandbox-ags:<your-tag> +# 或跑 pnpm setup:tcr 后使用生成的 TCR_IMAGE(会同步到 server .env) ``` +也可只写无 tag 的路径,由 `STATEFUL_SANDBOX_IMAGE_TAG` 补默认 tag。首次建 Tool 成功后,镜像 URI 已绑在沙箱 Tool 模板上,**之后可删掉 env**(仍靠 DB / `ovc-{TCB_ENV_ID}` 复用 Tool)。 + +> **隐私**:勿把 `TCB_SECRET_*`、`TCB_API_KEY`、`CODEBUDDY_API_KEY`、个人 TCR 密码等写入 README 或提交 git;`packages/server/.env` 已在 `.gitignore`。 + +### 本地开发(`pnpm dev`) + +| 变量 | 必需 | 说明 | +| --- | --- | --- | +| `JWE_SECRET` / `ENCRYPTION_KEY` | 是 | 会话与敏感字段加密(`init.sh` 可生成) | +| `TCB_ENV_ID` | 是 | 支撑环境 ID | +| `TCB_SECRET_ID` / `TCB_SECRET_KEY` | 是 | 管理面:建环境、沙箱 Tool/实例、provision | +| `TCB_API_KEY` | 是 | 数据面:gateway 访问 TRW(CloudBase 控制台创建 API Key) | +| `CODEBUDDY_API_KEY` 或 OAuth 一套 | 是 | Agent 调用 | +| `STATEFUL_SANDBOX_IMAGE` | 首次 `CreateSandboxTool` | 见上节;不配则用公开 TCR 默认或 `TCR_IMAGE` | +| `TCR_IMAGE` | 自管镜像时 | `pnpm setup:tcr` 推到**你的**命名空间后写入 | +| `SANDBOX_INSTANCE_MODE` | 否 | `shared`(默认)/ `isolated` — **沙箱实例**是否跨任务复用 | +| `TCB_PROVISION_MODE` | 否 | `shared` / `isolated` / `task` — **CloudBase 用户环境**隔离粒度 | +| `DB_PROVIDER` | 否 | 默认 `cloudbase`;本地纯离线可 `drizzle`(SQLite) | +| `PORT` | 否 | 默认 `3001` | +| `ASK_USER_BASE_URL` | 否 | 默认 `http://127.0.0.1:${PORT}`,OpenCode 子进程回调用 | + +**不要配(除非调试)**:`STATEFUL_TOOL_ID`、`STATEFUL_SANDBOX_ID`(固定实例)、`STATEFUL_GATEWAY_URL`(默认 `https://{TCB_ENV_ID}.api.tcloudbasegateway.com/v1/sandbox/-`)。 + +可选:`GITHUB_*`、`GIT_ARCHIVE_*`、`STATEFUL_MINIPROGRAM_FEATURE`、`STATEFUL_TOOL_WARMUP_*` 等见 [docs/setup.md](docs/setup.md)。 + +### 云托管(CloudRun) + +与本地 **同一套变量名**,在控制台配置;差异主要是运行形态: + +| 变量 | 云托管注意点 | +| --- | --- | +| `PORT` | 必须为 **80**(与 `Dockerfile` / `--port 80` 一致) | +| `NODE_ENV` | `production` | +| `JWE_SECRET` / `ENCRYPTION_KEY` | 与本地相同密钥体系,**勿**每次部署随机换(否则已有 session 失效) | +| `TCB_*` / `TCB_API_KEY` / `CODEBUDDY_*` | 与本地相同;Secret 走控制台「环境变量」,不要打进镜像 | +| `ASK_USER_BASE_URL` | 必须设为 **公网可访问的 OVC 根 URL**(如 `https://<云托管默认域名>`),不能依赖默认的 `127.0.0.1` | +| `STATEFUL_SANDBOX_IMAGE` | 首次在该环境创建 Tool 时需要;之后靠 DB 里的 `stateful_tool_id` | +| `STATEFUL_TOOL_ID` | **不要配**(多副本共用 DB 时也应走 DB + ToolName 逻辑) | + +云托管不跑 `pnpm dev`:无 Vite 代理,浏览器只访问容器内的 80 端口(静态 + API 一体)。 + --- ## OpenCode 模型配置 @@ -283,7 +371,7 @@ CLOUDBASE_API_KEY=eyJhbGciOiJS.xxxxxxxx | 后端 | Hono, Node.js, Drizzle ORM | | 数据库 | CloudBase DB(主),SQLite(本地回退) | | AI | `@tencent-ai/agent-sdk` (CodeBuddy), OpenCode ACP, MiMo | -| Sandbox | CloudBase AGS Stateful Sandbox, TRW, TCR 镜像 | +| Sandbox | 沙箱 infra(Stateful + TRW), TCR 镜像 | | 认证 | JWE session, bcrypt, Arctic (OAuth) | | 持久化 | CloudBase DB, 本地 .jsonl, Git archive | | 协议 | ACP (JSON-RPC 2.0 + SSE), MCP (Model Context Protocol) | @@ -300,7 +388,7 @@ CLOUDBASE_API_KEY=eyJhbGciOiJS.xxxxxxxx | -------- | -------------- | ------------------------------------------ | | 架构 | Next.js 全栈 | Monorepo 前后端分离(React + Vite / Hono) | | 部署 | Vercel | 腾讯云 CloudBase | -| Sandbox | Vercel Sandbox | CloudBase AGS Stateful Sandbox (TRW) | +| Sandbox | Vercel Sandbox | 沙箱 infra(Stateful + TRW) | | Agent | 单一 runtime | CodeBuddy / OpenCode / MiMo 多 runtime | | 环境隔离 | 无 | shared / isolated / task 三级 | diff --git a/docs/architecture.md b/docs/architecture.md index 1c1efa9..4a8a7cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ ## Overview -CloudBase VibeCoding Platform 是一个基于腾讯云 CloudBase 的 AI 编程助手平台。用户通过 Web 界面向 Agent 下达编程指令,Agent 在 **AGS Stateful Sandbox**(TRW 数据面 + gateway 路由)中执行代码操作,结果通过 SSE 流式返回并持久化到 CloudBase 数据库。 +CloudBase VibeCoding Platform 是一个基于腾讯云 CloudBase 的 AI 编程助手平台。用户通过 Web 界面向 Agent 下达编程指令,Agent 在 **沙箱 infra**(Stateful 控制面 + TRW 数据面 + gateway 路由)中执行代码操作,结果通过 SSE 流式返回并持久化到 CloudBase 数据库。 ```mermaid graph TB @@ -27,7 +27,7 @@ graph TB subgraph Infra["CloudBase Infrastructure"] DB[("CloudBase DB")] - AGS["AGS Stateful 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 --> AGS - TaskSvc --> AGS + MCPMiddleware --> SbxInfra + TaskSvc --> SbxInfra RuntimeMgr --> Persist --> DB - AGS --> CNB - AGS --> TCR + SbxInfra --> CNB + SbxInfra --> TCR TaskSvc --> Storage Auth --> EnvPool --> DB ``` @@ -251,15 +251,15 @@ Agent 调用子 Agent 时,`parent_tool_use_id` 从 SDK 顶层透传至前端 ` ## Sandbox Module -Sandbox 模块为每个 **envId** 提供 Stateful 执行环境(AGS 控制面 + TRW 数据面),任务共享同一实例上的 `/home/user` 工作区。 +Sandbox 模块为每个 **envId** 提供 Stateful 执行环境(沙箱 infra 控制面 + TRW 数据面),任务共享同一实例上的 `/home/user` 工作区。 ### Architecture ```mermaid flowchart LR Agent["Agent Runtime"] --> Provider["StatefulSandboxProvider"] - Provider --> AGS["AGS StartSandboxInstance"] - AGS --> TRW["TRW :9000"] + Provider --> SbxInfra["启动沙箱实例"] + SbxInfra --> TRW["TRW :9000"] TRW --> FS["File System /home/user"] TRW --> Bash["Bash / PTY"] TRW --> Preview["/preview/5173 / 7681"] @@ -274,8 +274,8 @@ flowchart LR ### Stateful Sandbox Lifecycle -1. **Ensure Tool** — `ensureStatefulTool(envId)` 为环境创建或复用 AGS Sandbox Tool(`sdt-xxx`),镜像来自 `STATEFUL_SANDBOX_IMAGE` / TCR -2. **Start / Reuse Instance** — `StatefulSandboxProvider` 按 envId 单实例:running 复用、paused 恢复、缺失则 `StartSandboxInstance`;镜像更新后 `stateful-tool-warmup` 轮询预热 +1. **Ensure Tool** — `ensureStatefulTool(envId)` 为环境创建或复用沙箱 Tool 模板(`sdt-xxx`),镜像来自 `STATEFUL_SANDBOX_IMAGE` → `TCR_IMAGE` → 公开 TCR 代码默认 +2. **Acquire Instance** — `SANDBOX_INSTANCE_MODE`:`shared` 每 env 单实例;`isolated` 每 task。`StatefulSandboxProvider`:running 复用、paused 恢复、缺失则 `StartSandboxInstance`;`stateful-tool-warmup` 轮询预热 3. **Data Plane** — `TCB_API_KEY` + `E2b-Sandbox-Id` / `E2b-Sandbox-Port: 9000` 经 gateway 访问 TRW 4. **Init Workspace** — `PUT /api/workspace/env` 注入凭证,`POST /api/workspace/init` 初始化 `/home/user` 5. **Execute** — Agent 工具经 TRW `/api/tools/*`;CloudBase MCP 由 server 侧 `stateful-mcp-client` 转发 @@ -411,7 +411,7 @@ sequenceDiagram participant Web participant Server participant Runtime as ACP Runtime - participant Sandbox as AGS + TRW + participant Sandbox as 沙箱 infra + TRW participant DB as CloudBase DB participant Git as Git Archive @@ -453,7 +453,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 AGS Stateful Sandbox, TRW, TCR images | +| Sandbox | 沙箱 infra(Stateful + TRW), 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) | diff --git a/docs/scf-session-sharing.md b/docs/scf-session-sharing.md index 90df671..7da4688 100644 --- a/docs/scf-session-sharing.md +++ b/docs/scf-session-sharing.md @@ -1,6 +1,6 @@ # SCF 沙箱 Session 共享改造方案 -> **已废弃**:`feature/stateful-infra` 已迁移至 **CloudBase AGS Stateful Sandbox + TRW**(`StatefulSandboxProvider`、`ensureStatefulTool`)。下文描述的是旧 SCF 架构,仅供历史对照;新部署见 [setup.md](./setup.md) 与 [architecture.md](./architecture.md) 的 Sandbox 章节。 +> **已废弃**:`feature/stateful-infra` 已迁移至 **沙箱 infra(Stateful + TRW)**(`StatefulSandboxProvider`、`ensureStatefulTool`)。下文描述的是旧 SCF 架构,仅供历史对照;新部署见 [setup.md](./setup.md) 与 [architecture.md](./architecture.md) 的 Sandbox 章节。 ## 背景 diff --git a/docs/setup.md b/docs/setup.md index 29fe1af..d7eaed5 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -12,7 +12,7 @@ ### 必需项 - **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 已安装并已启动(**stateful 分支**:本地只跑 OVC,沙箱连云端 AGS+TRW,无需本机 `tcb-sandbox serve`) +- Docker 已安装并已启动(**stateful 分支**:本地只跑 OVC,沙箱连云端沙箱 infra + TRW,无需本机 `tcb-sandbox serve`) - 腾讯云账号,且已准备 CloudBase 环境 - 可用的腾讯云 API 密钥(`SecretId` / `SecretKey`) - 至少一种 CodeBuddy 认证方式: @@ -93,7 +93,7 @@ flowchart TD - CloudBase 相关配置(`TCB_ENV_ID`、`TCB_SECRET_ID`、`TCB_SECRET_KEY`) - CodeBuddy 认证配置 - 数据库提供方配置 -- Stateful Sandbox(AGS + TRW)/ TCR 镜像配置 +- Stateful 沙箱 infra(控制面 + TRW 数据面)/ TCR 镜像配置 - 可选的 GitHub OAuth、代理配置 初始化脚本会优先把 CloudBase 和服务端相关配置写入这里。 @@ -125,12 +125,27 @@ flowchart TD | 变量 | 必需 | 说明 | | --- | --- | --- | | `TCB_API_KEY` | 是 | gateway 数据面 Bearer(与 `TCB_ENV_ID` 配套) | -| `STATEFUL_TOOL_ID` | 建议 | 已有 vibecoding SDT 时填写,跳过 `CreateSandboxTool` | -| `STATEFUL_SANDBOX_IMAGE` | 创建 tool 时必需 | AGS 工具镜像 URI(通常与 `TCR_IMAGE` 相同) | +| `STATEFUL_TOOL_ID` | **否(仅调试)** | 跳过 DB/创建;正常由 Tool 名 `ovc-{TCB_ENV_ID}` + DB 解析,见 README | +| `STATEFUL_SANDBOX_IMAGE` | 首次 `CreateSandboxTool` | TCR 完整 URI;不配则用代码公开默认或 `TCR_IMAGE`(见 README) | +| `STATEFUL_SANDBOX_IMAGE_TAG` | 否 | URI 无 `:tag` 时补全 | +| `SANDBOX_INSTANCE_MODE` | 否 | `shared` / `isolated` — **沙箱实例**是否跨任务复用(写在 `packages/server/.env`) | | `STATEFUL_GATEWAY_URL` | 否 | 默认 `https://{TCB_ENV_ID}.api.tcloudbasegateway.com/v1/sandbox/-` | | `STATEFUL_SANDBOX_ID` | 否 | 调试时固定实例 ID | | `STATEFUL_TOOL_WARMUP_POLL_MS` / `STATEFUL_TOOL_WARMUP_POLL_MAX` | 否 | 镜像更新后预热轮询(默认 10s × 6) | -| `TCR_IMAGE` | 建议 | `setup-tcr` 写入;供 `STATEFUL_SANDBOX_IMAGE` 引用 | +| `TCR_IMAGE` | 自管镜像时 | `pnpm setup:tcr` 写入**你的**命名空间;可当作 `STATEFUL_SANDBOX_IMAGE` | + +**镜像**:须为 `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 密码。 + +### 沙箱实例模式(`SANDBOX_INSTANCE_MODE`) + +与 **`TCB_PROVISION_MODE`(用户 CloudBase 环境)** 独立,详见 README「两套共享/隔离」。 + +| 值 | 行为 | +| --- | --- | +| `shared`(默认) | 同一支撑 `TCB_ENV_ID` 下,多任务复用沙箱 infra 上同一运行实例(`ensureSingleEnvInstance`) | +| `isolated` | 每任务独立实例;优先复用任务上的 `sandboxId`,否则新建 | + +配置位置:`packages/server/.env` 的 `SANDBOX_INSTANCE_MODE`;Admin「系统设置」里的 `sandbox_instance_mode`(DB)优先级更高。改模式后**新建任务**最可靠;旧任务若 DB 里已写死 `sandboxMode` 可能仍为旧值。 ## 用户环境模式 diff --git a/packages/server/src/sandbox/ensure-stateful-tool.ts b/packages/server/src/sandbox/ensure-stateful-tool.ts index 30213f1..ce548b2 100644 --- a/packages/server/src/sandbox/ensure-stateful-tool.ts +++ b/packages/server/src/sandbox/ensure-stateful-tool.ts @@ -3,21 +3,67 @@ * Persists tool id in settings (shared) or user_resources (isolated/task scope). */ -import { nanoid } from 'nanoid' 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' export const STATEFUL_TOOL_SETTINGS_KEY = 'stateful_tool_id' const DEFAULT_TOOL_ROLE_ARN = 'qcs::cam::uin/691612481:roleName/agent-sandbox' -function sanitizeToolName(envId: string): string { +/** 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 `ovc-${slug || 'default'}` } +function extractSandboxToolSet(resp: Record<string, unknown>): Array<Record<string, unknown>> { + const set = resp.SandboxToolSet + if (Array.isArray(set)) return set + const nested = (resp.data as Record<string, unknown> | undefined)?.SandboxToolSet + return Array.isArray(nested) ? nested : [] +} + +function pickToolIdByName(tools: Array<Record<string, unknown>>, 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<string | null> { + 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 { const explicit = process.env.STATEFUL_GATEWAY_URL || process.env.STATEFUL_SANDBOX_URL || '' if (explicit) return explicit.replace(/\/$/, '') @@ -50,13 +96,13 @@ async function callAgsManagerApi(action: string, param: Record<string, unknown>) } async function createSandboxTool(envId: string): Promise<string> { - const image = process.env.STATEFUL_SANDBOX_IMAGE || '' + const image = resolveStatefulSandboxImage() if (!image) { - throw new Error('Missing STATEFUL_SANDBOX_IMAGE (vibecoding preset image URI for CreateSandboxTool)') + throw new Error(formatMissingStatefulSandboxImageError()) } const roleArn = process.env.STATEFUL_TOOL_ROLE_ARN || DEFAULT_TOOL_ROLE_ARN - const toolName = sanitizeToolName(envId) + const toolName = statefulToolNameForEnv(envId) const data = { ToolName: toolName, @@ -64,7 +110,7 @@ async function createSandboxTool(envId: string): Promise<string> { RoleArn: roleArn, CustomConfiguration: { Image: image, - ImageRegistryType: process.env.STATEFUL_IMAGE_REGISTRY || 'personal', + ImageRegistryType: resolveStatefulImageRegistryType(image), Command: JSON.parse(process.env.STATEFUL_TOOL_COMMAND || '["/init"]'), Resources: { CPU: process.env.STATEFUL_TOOL_CPU || '2', @@ -139,17 +185,38 @@ async function persistToolId(envId: string, toolId: string, userId?: string, tas } /** - * Resolve ToolId for envId: DB → env override → CreateTool. + * Resolve ToolId for envId: debug override → DB → AGS lookup by ToolName → CreateTool. */ export async function ensureStatefulTool( envId: string, opts?: { userId?: string; taskId?: string; onProgress?: SandboxProgressCallback }, ): Promise<string> { const override = process.env.STATEFUL_TOOL_ID || process.env.STATEFUL_SANDBOX_TOOL_ID || '' - if (override) return override + 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) return existing + if (existing) { + opts?.onProgress?.({ + phase: 'template_resolve', + message: '使用已登记的沙箱模板...\n', + }) + return existing + } + + 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) + return byName + } opts?.onProgress?.({ phase: 'template_create', diff --git a/packages/server/src/sandbox/provider/stateful-provider.ts b/packages/server/src/sandbox/provider/stateful-provider.ts index 9b5b992..ff709dd 100644 --- a/packages/server/src/sandbox/provider/stateful-provider.ts +++ b/packages/server/src/sandbox/provider/stateful-provider.ts @@ -8,9 +8,11 @@ * - POST /api/workspace/snapshot explicit COS snapshot flush * * Two-layer control plane: - * - Tool = template (sdt-xxx). ensureStatefulTool() per envId (manager-node CreateSandboxTool). - * - Instance = runtime container. Provider enforces single-instance lifecycle - * per envId: running->reuse, paused->resume, missing->create. + * - 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 (long-lived JWT) used as X-Cloudbase-Authorization Bearer. * Routing: E2b-Sandbox-Id + E2b-Sandbox-Port: 9000 headers route to instance. @@ -273,6 +275,10 @@ async function ensureSingleEnvInstance( 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 } } @@ -288,7 +294,16 @@ async function ensureSingleEnvInstance( } 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 } } @@ -305,12 +320,25 @@ async function ensureTaskInstance( 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 } } @@ -369,7 +397,10 @@ class StatefulProvider implements SandboxProvider { if (cached) { const headers = await cached.getAuthHeaders() if (await checkHealth(cached.baseUrl, headers)) { - onProgress?.({ phase: 'reuse', message: '复用已有沙箱...\n' }) + onProgress?.({ + phase: 'instance_reuse_session', + message: '复用本会话的沙箱连接...\n', + }) return cached } this.instanceCache.delete(key) @@ -380,7 +411,6 @@ class StatefulProvider implements SandboxProvider { onProgress?.({ phase: 'wait_ready', message: '连接已有沙箱实例...\n' }) sandboxId = cfg.preCreatedSandboxId } else { - onProgress?.({ phase: 'create', message: '正在启动云端沙箱实例...\n' }) if (sandboxMode === 'isolated') { const ensured = await ensureTaskInstance(cfg, cfg.toolId, preferredSandboxId, onProgress) sandboxId = ensured.sandboxId @@ -388,7 +418,7 @@ class StatefulProvider implements SandboxProvider { const ensured = await ensureSingleEnvInstance(cfg, cfg.toolId, onProgress) sandboxId = ensured.sandboxId } - onProgress?.({ phase: 'wait_ready', message: '等待沙箱实例就绪...\n' }) + onProgress?.({ phase: 'wait_ready', message: '确认沙箱实例健康状态...\n' }) await waitForReady(cfg.sandboxBaseUrl, sandboxId, cfg.tcbApiKey) } diff --git a/packages/server/src/sandbox/stateful-tool-warmup.ts b/packages/server/src/sandbox/stateful-tool-warmup.ts index 7ca628f..81f8761 100644 --- a/packages/server/src/sandbox/stateful-tool-warmup.ts +++ b/packages/server/src/sandbox/stateful-tool-warmup.ts @@ -13,18 +13,24 @@ function sleep(ms: number): Promise<void> { return new Promise((r) => setTimeout(r, ms)) } -function emitWarmupProgress(onProgress: SandboxProgressCallback | undefined, round: number, hint?: string): void { - const suffix = hint ? `(${hint})` : '' +function emitToolWarmupProgress(onProgress: SandboxProgressCallback | undefined): void { onProgress?.({ phase: 'template_warmup', - message: `沙箱模板预热中 ${round}/${WARMUP_POLL_MAX}${suffix}...\n`, + 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<void> { for (let round = 1; round <= WARMUP_POLL_MAX; round++) { - emitWarmupProgress(onProgress, round, '云平台拉取镜像') + emitToolWarmupProgress(onProgress) await sleep(WARMUP_POLL_MS) } } @@ -43,7 +49,7 @@ export async function startStatefulInstanceWithWarmup( if (!isAgsRetryableError(err) || attempt >= WARMUP_POLL_MAX) { throw err } - emitWarmupProgress(onProgress, attempt, '镜像尚未就绪,稍后重试') + emitInstanceStartProgress(onProgress) await sleep(WARMUP_POLL_MS) } } 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..835136d --- /dev/null +++ b/packages/server/src/sandbox/stateful-vibecoding-image.ts @@ -0,0 +1,91 @@ +/** + * 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 OVC / 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.OVC_PUBLIC_TCR_REPO?.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() || '260521-1705-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<string, string> | null = null + +function loadRepoRootEnvLocal(): Record<string, string> { + 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/web/src/components/chat/agent-status-indicator.tsx b/packages/web/src/components/chat/agent-status-indicator.tsx index 4a8071c..f034ff6 100644 --- a/packages/web/src/components/chat/agent-status-indicator.tsx +++ b/packages/web/src/components/chat/agent-status-indicator.tsx @@ -43,10 +43,24 @@ const PHASE_CONFIG: Record<AgentPhaseName, PhaseConfig> = { iconClass: 'text-blue-500', label: (toolName) => { switch (toolName) { + case 'sandbox:template_resolve': + return '加载沙箱模板...' + case 'sandbox:template_bind': + return '绑定已有沙箱模板...' case 'sandbox:template_create': return '沙箱模板生成中(本环境仅首次)...' case 'sandbox:template_warmup': return '沙箱模板预热中...' + case 'sandbox:instance_reuse_session': + return '复用本会话沙箱连接...' + case 'sandbox:instance_reuse_shared': + return '复用环境沙箱(多任务共享)...' + case 'sandbox:instance_reuse_task': + return '复用任务沙箱...' + case 'sandbox:instance_resume': + return '恢复沙箱实例...' + case 'sandbox:instance_start': + return '启动沙箱实例...' case 'sandbox:reuse': return '连接已有沙箱...' case 'sandbox:create': @@ -54,7 +68,7 @@ const PHASE_CONFIG: Record<AgentPhaseName, PhaseConfig> = { case 'sandbox:wait_creating': return '沙箱启动中...' case 'sandbox:pull_image': - return '拉取镜像...' + return '沙箱实例镜像拉取中...' case 'sandbox:wait_ready': return '等待沙箱就绪...' case 'sandbox:init_mcp': diff --git a/packages/web/src/pages/admin/settings-page.tsx b/packages/web/src/pages/admin/settings-page.tsx index a4006c6..26ad02b 100644 --- a/packages/web/src/pages/admin/settings-page.tsx +++ b/packages/web/src/pages/admin/settings-page.tsx @@ -33,13 +33,13 @@ const SANDBOX_INSTANCE_MODES = [ { value: 'shared', label: '共享实例', - description: '同一 CloudBase 环境下的所有任务共用一个 AGS 沙箱实例(共盘 /home/user)', + description: '同一 CloudBase 环境下的所有任务共用一个沙箱运行时实例(共盘 /home/user)', badge: '默认', }, { value: 'isolated', label: '按任务隔离', - description: '每个任务独立一个 AGS 沙箱实例;删除任务会停止对应实例', + description: '每个任务独立一个沙箱运行时实例;删除任务会停止对应实例', badge: '隔离', }, ] as const @@ -259,7 +259,7 @@ export function AdminSettingsPage() { )} </div> <p className="text-sm text-muted-foreground"> - 控制同一 CloudBase 环境下,多个任务是否共用同一个 AGS 运行时实例(与上方「环境隔离」正交) + 控制同一 CloudBase 环境下,多个任务是否共用同一个沙箱运行时实例(与上方「环境隔离」正交) </p> <p className="text-xs text-muted-foreground"> 优先级:管理员设置(DB) > <code className="px-1 rounded bg-muted">SANDBOX_INSTANCE_MODE</code> > diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d09b6c9..63b73aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -364,12 +364,6 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@xterm/addon-fit': - specifier: ^0.10.0 - version: 0.10.0(@xterm/xterm@5.5.0) - '@xterm/xterm': - specifier: ^5.5.0 - version: 5.5.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2658,14 +2652,6 @@ packages: '@vue/shared@3.5.32': resolution: {integrity: sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==} - '@xterm/addon-fit@0.10.0': - resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} - peerDependencies: - '@xterm/xterm': ^5.0.0 - - '@xterm/xterm@5.5.0': - resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} - '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} @@ -8577,12 +8563,6 @@ snapshots: '@vue/shared@3.5.32': {} - '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': - dependencies: - '@xterm/xterm': 5.5.0 - - '@xterm/xterm@5.5.0': {} - '@yarnpkg/lockfile@1.1.0': {} accepts@2.0.0: diff --git a/scripts/setup-tcr.mjs b/scripts/setup-tcr.mjs index bdf997f..b3fb54a 100644 --- a/scripts/setup-tcr.mjs +++ b/scripts/setup-tcr.mjs @@ -34,6 +34,7 @@ const tencentcloud = require('tencentcloud-sdk-nodejs') const TCR_DOMAIN = 'ccr.ccs.tencentyun.com' const ENV_FILE = resolve(process.cwd(), '.env.local') +const SERVER_ENV_FILE = resolve(process.cwd(), 'packages/server/.env') const CLOUDBASE_AUTH_FILE = resolve(homedir(), '.config/.cloudbase/auth.json') const DEFAULT_NAMESPACE_PREFIX = 'cloudbase-vibecoding' // docker.io/yhyanghang/cloudbase-workspace:260515-0120e18d @@ -120,6 +121,38 @@ function loadEnvFile() { return env } +/** Mirror TCR image URI into packages/server/.env for first-time CreateSandboxTool. */ +function syncStatefulSandboxImageToServer(tcrImage) { + if (!existsSync(SERVER_ENV_FILE)) { + log('packages/server/.env not found; skip STATEFUL_SANDBOX_IMAGE sync', 'warn') + return + } + const key = 'STATEFUL_SANDBOX_IMAGE' + const content = readFileSync(SERVER_ENV_FILE, 'utf-8') + const line = `${key}=${tcrImage}` + const lines = content.split('\n') + let replaced = false + const newLines = lines.map((row) => { + const t = row.trim() + if (t.startsWith(`${key}=`) || t.startsWith(`# ${key}=`)) { + replaced = true + return line + } + return row + }) + if (!replaced) { + const marker = '# Stateful sandbox' + const idx = newLines.findIndex((row) => row.includes('STATEFUL') || row.includes('Stateful sandbox')) + if (idx >= 0) { + newLines.splice(idx + 1, 0, line) + } else { + newLines.push('', line) + } + } + writeFileSync(SERVER_ENV_FILE, newLines.join('\n')) + log('STATEFUL_SANDBOX_IMAGE synced to packages/server/.env', 'success') +} + function saveEnvVar(key, value) { const env = loadEnvFile() @@ -993,6 +1026,7 @@ async function setupTcr(config) { // Save image reference saveEnvVar('TCR_IMAGE', fullImage) + syncStatefulSandboxImageToServer(fullImage) log('Image reference saved', 'info') return true From b923df7eb6a44c6c033783c4c958ad6f42862218 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Thu, 21 May 2026 19:32:25 +0800 Subject: [PATCH 10/29] docs: record upstream hard fork and main sync through a878ddb Add docs/upstream-fork.md with fork baseline at 43c3e60 and 2026-05-21 merge history to origin/main a878ddb; align README upstream section. Co-authored-by: Cursor <cursoragent@cursor.com> --- README.md | 20 +++++---- docs/upstream-fork.md | 96 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 docs/upstream-fork.md diff --git a/README.md b/README.md index b85dd91..0064694 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - [Setup 指南](docs/setup.md) — 初始化流程、环境变量、验证清单与排障 - [系统架构](docs/architecture.md) — 系统分层、模块设计与关键数据流 +- [上游分叉与同步](docs/upstream-fork.md) — 硬分叉基线、merge 历史、下次同步命令 --- @@ -463,15 +464,18 @@ CODEBUDDY_USE_CUSTOM_MODELS=true ## 与上游的关系 -本项目 fork 自 Vercel 的 [coding-agent-template](https://github.com/vercel-labs/coding-agent-template)。主要差异: +- 最初模板:[vercel-labs/coding-agent-template](https://github.com/vercel-labs/coding-agent-template) +- **直接上游**:[TencentCloudBase/OpenVibeCoding](https://github.com/TencentCloudBase/OpenVibeCoding)(`origin`) -| | 上游 | 本项目 | -| -------- | -------------- | ------------------------------------------ | -| 架构 | Next.js 全栈 | Monorepo 前后端分离(React + Vite / Hono) | -| 部署 | Vercel | 腾讯云 CloudBase | -| Sandbox | Vercel Sandbox | 沙箱 infra(Stateful + TRW) | -| Agent | 单一 runtime | CodeBuddy / OpenCode / MiMo 多 runtime | -| 环境隔离 | 无 | shared / isolated / task 三级 | +**硬分叉基线**(不变):`43c3e6038d833481c2fd0d4d206f4a801de7a750`(2026-05-21,`feautre/env-pool` 合入点)。本线此后增加沙箱 infra,与上游 **不保证长期可 merge**。 + +**最近一次上游同步**(2026-05-21):`git merge origin/main`,已对齐至上游 `main` 的 `a878ddb`(CodeBuddy TokenHub、自定义模型、`codebuddy-setup`、agent 选项等)。完整记录见 [docs/upstream-fork.md](docs/upstream-fork.md)。 + +| | 上游 `main`(已 merge 部分) | 本线独有(`feature/stateful-infra`) | +| -------- | ---------------------------- | ---------------------------------------- | +| Agent | TokenHub、自定义模型、选项更新 | 同左(已并入) | +| Sandbox | 环境池 / 演进中 | 沙箱 infra(Stateful + TRW)、公开 TCR 默认 | +| 实例策略 | shared / isolated / task | + `SANDBOX_INSTANCE_MODE`、细粒度进度文案 | --- diff --git a/docs/upstream-fork.md b/docs/upstream-fork.md new file mode 100644 index 0000000..d34168c --- /dev/null +++ b/docs/upstream-fork.md @@ -0,0 +1,96 @@ +# 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 + TRW) | + +## 硬分叉基线(不变) + +首次从上游拉出本线时的截止点,**不随后续 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 数据面、TRW vibecoding 镜像 +- `SANDBOX_INSTANCE_MODE`(shared / isolated)与进度文案 +- 公开 TCR 默认镜像、`stateful-vibecoding-image` 解析链 +- 与 SCF 时代假设脱钩的文档与默认配置 + +## 上游同步记录 + +| 日期 | 方式 | 上游 `main` 顶 | 本线 merge commit | 备注 | +| --- | --- | --- | --- | --- | +| 2026-05-21 | `git merge origin/main` | `a878ddbbee2f6320395dc7f84a7e6a068c524e75` | `20dedbdbb00997d8f23c289317836de14df44d60` | 无冲突;含下方 5 个上游 commit | + +**本次并入的上游 commit**(`43c3e60..a878ddb`): + +| SHA | 说明 | +| --- | --- | +| `a5543ba` | feat: 优化 opencode 安装描述 | +| `a774c74` | feat: codebuddy 支持 tokenhub | +| `03745a9` | feat: 初始化添加配置自定义模型功能 | +| `4669043` | Merge pull request #23(CodeBuddy TokenHub) | +| `a878ddb` | feat: 更新 agent 选项 | + +**当前对齐状态**(2026-05-21 merge 后): + +- `git merge-base HEAD origin/main` → `a878ddb`(本分支已包含当时上游 `main` 全部历史) +- 本线 **独有** 提交仍在 `a878ddb` 之上(stateful-infra、沙箱进度、TCR 默认等) + +下次看上游新提交: + +```bash +git fetch origin +git log a878ddbbee2f6320395dc7f84a7e6a068c524e75..origin/main --oneline +``` + +## 偶尔从上游同步(推荐流程) + +```bash +git fetch origin + +# 自上次对齐的顶往下看 +git log a878ddbbee2f6320395dc7f84a7e6a068c524e75..origin/main --oneline + +# 整分支合并(可能冲突,需人工解) +git merge origin/main + +# 或单 commit +git cherry-pick <upstream-sha> +``` + +大范围对齐后:在 **上游同步记录** 表追加一行,并视情况把「自上次对齐的顶」更新为新 `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` 二选一即可。 From 5952631f521f1594bfce4598862e31a42246ad95 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Mon, 25 May 2026 20:50:58 +0800 Subject: [PATCH 11/29] feat(web): logs pane terminal clear/copy controls Expose TerminalRef from the terminal tab and add toolbar actions; use a stub terminal placeholder until ttyd preview is wired again. Co-authored-by: Cursor <cursoragent@cursor.com> --- packages/web/src/components/logs-pane.tsx | 47 ++++++++++++++++++++++- packages/web/src/components/terminal.tsx | 41 +++++--------------- 2 files changed, 55 insertions(+), 33 deletions(-) diff --git a/packages/web/src/components/logs-pane.tsx b/packages/web/src/components/logs-pane.tsx index 5ba4690..d8e8913 100644 --- a/packages/web/src/components/logs-pane.tsx +++ b/packages/web/src/components/logs-pane.tsx @@ -6,7 +6,7 @@ import { useState, useEffect, useRef } from 'react' import { toast } from 'sonner' import { useTasks } from '@/components/app-layout' import { getLogsPaneHeight, setLogsPaneHeight, getLogsPaneCollapsed, setLogsPaneCollapsed } from '@/lib/utils/cookies' -import { Terminal } from '@/components/terminal' +import { Terminal, TerminalRef } from '@/components/terminal' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' interface LogsPaneProps { @@ -19,6 +19,7 @@ type LogFilterType = 'all' | 'platform' | 'server' export function LogsPane({ task, onHeightChange }: LogsPaneProps) { const [copiedLogs, setCopiedLogs] = useState(false) + const [copiedTerminal, setCopiedTerminal] = useState(false) const [isCollapsed, setIsCollapsedState] = useState(true) const [paneHeight, setPaneHeight] = useState(200) const [isResizing, setIsResizing] = useState(false) @@ -28,6 +29,7 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { const [isClearingLogs, setIsClearingLogs] = useState(false) const [logFilter, setLogFilter] = useState<LogFilterType>('all') const logsContainerRef = useRef<HTMLDivElement>(null) + const terminalRef = useRef<TerminalRef>(null) const prevLogsLengthRef = useRef<number>(0) const hasInitialScrolled = useRef<boolean>(false) const wasAtBottomRef = useRef<boolean>(true) @@ -199,6 +201,25 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { } } + const clearTerminal = () => { + if (terminalRef.current) { + terminalRef.current.clear() + } + } + + const copyTerminalToClipboard = async () => { + if (terminalRef.current) { + try { + const terminalText = terminalRef.current.getTerminalText() + await navigator.clipboard.writeText(terminalText) + setCopiedTerminal(true) + setTimeout(() => setCopiedTerminal(false), 2000) + } catch { + toast.error('Failed to copy terminal to clipboard') + } + } + } + const handleMouseDown = (e: React.MouseEvent) => { e.preventDefault() setIsResizing(true) @@ -302,6 +323,28 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { </Button> </div> )} + {activeTab === 'terminal' && ( + <div className="flex items-center gap-1 mr-3" onClick={(e) => e.stopPropagation()}> + <Button + variant="ghost" + size="sm" + onClick={clearTerminal} + className="h-5 w-5 p-0 hover:bg-accent" + title="Clear terminal" + > + <Trash2 className="h-3 w-3" /> + </Button> + <Button + variant="ghost" + size="sm" + onClick={copyTerminalToClipboard} + className="h-5 w-5 p-0 hover:bg-accent" + title="Copy terminal to clipboard" + > + {copiedTerminal ? <Check className="h-3 w-3 text-green-600" /> : <Copy className="h-3 w-3" />} + </Button> + </div> + )} </div> <div ref={logsContainerRef} @@ -352,10 +395,10 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { </div> <div className={cn('flex-1 overflow-hidden', (isCollapsed || activeTab !== 'terminal') && 'hidden')}> <Terminal + ref={terminalRef} taskId={task.id} isActive={activeTab === 'terminal' && !isCollapsed} isMobile={!isDesktop} - sandboxReady={!!task.sandboxId} /> </div> </div> diff --git a/packages/web/src/components/terminal.tsx b/packages/web/src/components/terminal.tsx index 3a87092..e6ffc8f 100644 --- a/packages/web/src/components/terminal.tsx +++ b/packages/web/src/components/terminal.tsx @@ -1,15 +1,10 @@ -/** - * Web terminal via TRW ttyd — virtual port 7681, proxied as /api/tasks/:id/preview/7681/. - */ - -import { forwardRef, useImperativeHandle } from 'react' +import { useState, useRef, useImperativeHandle, forwardRef } from 'react' interface TerminalProps { taskId: string className?: string isActive?: boolean isMobile?: boolean - sandboxReady?: boolean } export interface TerminalRef { @@ -18,36 +13,20 @@ export interface TerminalRef { } export const Terminal = forwardRef<TerminalRef, TerminalProps>(function Terminal( - { taskId, className, isActive, sandboxReady = true }, + { taskId: _taskId, className: _className, isActive: _isActive, isMobile: _isMobile }, ref, ) { + const [text, setText] = useState('') + const containerRef = useRef<HTMLDivElement>(null) + useImperativeHandle(ref, () => ({ - clear: () => {}, - getTerminalText: () => '', + clear: () => setText(''), + getTerminalText: () => text, })) - if (!sandboxReady) { - return ( - <div - className={`h-full bg-black text-muted-foreground p-2 font-mono text-xs flex items-center justify-center ${className ?? ''}`} - > - 沙箱未就绪,终端暂不可用 - </div> - ) - } - - if (!isActive) { - return <div className={`h-full min-h-0 bg-black ${className ?? ''}`} /> - } - - const src = `/api/tasks/${taskId}/preview/7681/` - return ( - <iframe - src={src} - title="Web Terminal" - className={`h-full min-h-0 w-full border-0 bg-black ${className ?? ''}`} - allow="clipboard-read; clipboard-write" - /> + <div ref={containerRef} className="h-full bg-black text-green-400 p-2 font-mono text-xs overflow-y-auto"> + <div className="text-muted-foreground">Terminal (stub) - not yet connected</div> + </div> ) }) From 06993231712611e99d4092f5ded8da55a071eeaa Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Mon, 25 May 2026 20:52:29 +0800 Subject: [PATCH 12/29] feat(stateful): AGS sandbox lifecycle, TRW alignment, and UI progress Inject git archive via workspace/env after instance start (avoid boot-time probe failures). Reconcile tool image without dropping Ports/Probe; improve shared-instance progress and file browser. Add TRW miniprogram client and API alignment docs. Co-authored-by: Cursor <cursoragent@cursor.com> --- .env.example | 23 +- docs/architecture.md | 6 +- docs/trw-api-alignment.md | 40 +++ .../server/scripts/debug-start-instance.ts | 21 +- .../server/scripts/stop-stateful-instances.ts | 30 ++- .../src/agent/cloudbase-agent.service.ts | 45 +++- packages/server/src/agent/coding-mode.ts | 5 + .../server/src/agent/runtime/base-runtime.ts | 66 ++++- .../src/agent/runtime/opencode-acp-runtime.ts | 7 +- packages/server/src/lib/sandbox-config.ts | 14 + .../src/middleware/mcp/cloudbase/README.md | 3 +- .../mcp/cloudbase/getDeployJobStatus.ts | 17 +- .../mcp/cloudbase/publishMiniprogram.ts | 73 ++---- packages/server/src/routes/tasks.ts | 7 +- .../src/sandbox/ensure-stateful-tool.ts | 68 ++++- packages/server/src/sandbox/git-archive.ts | 50 +++- .../src/sandbox/provider/stateful-provider.ts | 8 + .../sandbox/stateful-custom-configuration.ts | 44 ++++ .../sandbox/stateful/stateful-mcp-client.ts | 242 +++++++----------- packages/server/src/sandbox/task-sandbox.ts | 6 +- .../src/sandbox/trw-miniprogram-client.ts | 121 +++++++++ .../chat/agent-status-indicator.tsx | 2 + packages/web/src/components/file-browser.tsx | 54 +++- packages/web/src/components/task-chat.tsx | 10 +- 24 files changed, 699 insertions(+), 263 deletions(-) create mode 100644 docs/trw-api-alignment.md create mode 100644 packages/server/src/sandbox/stateful-custom-configuration.ts create mode 100644 packages/server/src/sandbox/trw-miniprogram-client.ts diff --git a/.env.example b/.env.example index adde478..0d66356 100644 --- a/.env.example +++ b/.env.example @@ -54,7 +54,7 @@ MAX_SANDBOX_DURATION=300 # TCB_API_KEY= # gateway JWT (sandbox apikey create) # STATEFUL_TOOL_ID= # DEBUG ONLY — skips DB/CreateTool; normal flow uses ToolName ovc-{TCB_ENV_ID} -# STATEFUL_SANDBOX_IMAGE= # 首次 CreateSandboxTool;不配则用代码公开 TCR 默认或 TCR_IMAGE +# STATEFUL_SANDBOX_IMAGE= # CreateSandboxTool + 已有 SDT 漂移时自动 UpdateSandboxTool;不配则用公开 TCR 默认或 TCR_IMAGE # 公开默认见 packages/server/src/sandbox/stateful-vibecoding-image.ts(团队公开 ns,非你的密钥) # 自管镜像示例: ccr.ccs.tencentyun.com/<your-namespace>/tcb-sandbox-ags:<YYMMDD-HHMM-xxxx-vibecoding> # STATEFUL_SANDBOX_IMAGE_TAG= # URI 无 :tag 时补全(默认与代码常量一致) @@ -79,13 +79,26 @@ MAX_SANDBOX_DURATION=300 # GITHUB_CLIENT_ID= # GITHUB_CLIENT_SECRET= -# ==================== Git Archive (Optional) ==================== - -# 工作区 Git 归档配置 -# GIT_ARCHIVE_REPO= +# ==================== Git push(两套独立能力,建议用两个不同仓库验收) ==================== +# +# Archive(团队归档)— 配下面三项即可;不用在 UI Link。 +# 何时推:agent 一轮结束、编辑器保存文件、删 task 等(TRW /api/extend/git_push) +# 验收:server 日志 [GitArchive] Push completed;只看 GIT_ARCHIVE_REPO 对应远端 +# +# Link(用户仓库)— 配 GIT_PERSONAL_AUTH;仓库 URL+分支在任务菜单「Link Git Repository」里填(不进 env)。 +# 何时推:Link 成功后,agent 一轮结束(沙箱内 git push) +# 验收:只看你在 UI 里 Link 的那个库(例如 tcb_test_link),与归档库无关 +# +# 改 GIT_ARCHIVE_* 或实例级 env 后:重启 server,并停旧 AGS 实例再起新实例。 + +# --- Archive:平台统一备份仓(勿与 Link 测试库混用) --- +# GIT_ARCHIVE_REPO=https://github.com/<org>/<archive-repo>.git # GIT_ARCHIVE_USER= # GIT_ARCHIVE_TOKEN= +# --- Link:推用户自有仓库用的凭证(仓库地址在 UI Link,不写 env) --- +# GIT_PERSONAL_AUTH=ghp_... # 需对 Link 的库有 push 权限;可与 ARCHIVE token 相同 PAT,但 remote 必须是两个库 + # ==================== Proxy (Optional) ==================== # http_proxy= diff --git a/docs/architecture.md b/docs/architecture.md index 4a8a7cb..f0b8eff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -300,7 +300,9 @@ flowchart LR | Bash | `/api/tools/bash` | Shell 命令执行 | | Web Terminal | `/preview/7681/` (ttyd) | 浏览器终端 | | Vite preview | `/preview/5173/` | 开发服务器(默认端口 5173) | -| Git Push | `POST /api/extend/git_push` | 工作区推送到远端(`ENABLE_GIT_ARCHIVE`) | +| Git Push | `POST /api/extend/git_push` | 工作区推送到远端(TRW `ENABLE_GIT_ARCHIVE` + OVC `GIT_ARCHIVE_*`) | +| Miniprogram deploy | `POST /api/jobs/miniprogram-deploy` | 启动部署 job(TRW `ENABLE_VIBECODING`) | +| Job poll | `GET /api/jobs/:jobId` | 轮询 job 状态 / 日志 | | Health | `/health` | 实例健康检查 | > **已移除**:旧 SCF 时代的子工作区 Scope API(`X-Scope-Id`、`/api/scope/info`、5173–5199 多端口)。`user_resources.scope` 仍指 **CloudBase 环境隔离**(shared / isolated / task),与 TRW Scope 无关。 @@ -372,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 时 TRW 返回 HTTP 202 + `jobId`;Agent 用 `getDeployJobStatus` 轮询 `GET /api/jobs/:jobId`(OVC 经 `trw-deploy-adapter` 转成旧 envelope)。 前端 Deployments 标签页统一渲染所有 deployment 记录,根据字段自动选择卡片样式(链接卡片 / 二维码卡片 / 通用卡片)。 diff --git a/docs/trw-api-alignment.md b/docs/trw-api-alignment.md new file mode 100644 index 0000000..a46f8b8 --- /dev/null +++ b/docs/trw-api-alignment.md @@ -0,0 +1,40 @@ +# OVC ↔ TRW API 对齐 + +对照仓库:`tcb-remote-workspace`(`ENABLE_VIBECODING` + `ENABLE_GIT_ARCHIVE` preset)。 +上游路由台账:`tcb-remote-workspace/docs/vibecoding-branch-sync.md`。 + +## TRW 路由(OVC 会调用的) + +| 方法 | 路径 | TRW 开关 | OVC 调用处 | +| --- | --- | --- | --- | +| GET | `/health` | 始终 | `base-runtime` 探活 | +| PUT | `/api/workspace/env` | 始终 | `stateful-provider`、`cloudbase-mcp` 注入凭证 | +| POST | `/api/workspace/init` | 始终 | `stateful-provider` 初始化工作区 | +| POST | `/api/workspace/snapshot` | 始终 | `stateful-provider` 可选快照 | +| POST | `/api/tools/:tool` | 始终 | mcporter / bash / read 等 | +| GET | `/preview/ports` | vibecoding | `tasks.ts`、`wait-vite-ready` | +| GET | `/preview/:port` | vibecoding | 预览代理 | +| POST | `/api/extend/git_push` | `ENABLE_GIT_ARCHIVE` | `git-archive.ts` | +| POST | `/api/jobs/miniprogram-deploy` | `ENABLE_VIBECODING` | `trw-miniprogram-client.ts` | +| GET | `/api/jobs/:jobId` | `ENABLE_VIBECODING` | 同上(轮询) | + +## 已废弃(OVC 不再调用) + +| 旧路径 | 替代 | +| --- | --- | +| `POST /api/session/init` | `POST /api/workspace/init` | +| `PUT /api/session/env` | `PUT /api/workspace/env` | +| `GET /api/scope/info` | 无(单工作区 `/home/user`) | +| `POST /api/miniprogram/deploy` | `POST /api/jobs/miniprogram-deploy` | +| `GET /api/miniprogram/deploy/status` | `GET /api/jobs/:jobId` | +| `POST /api/tools/git_push` | `POST /api/extend/git_push` | +| `POST /api/tools/miniprogram_deploy` | 仅 jobs HTTP | + +## OVC 实现要点 + +- **共享客户端**:`packages/server/src/sandbox/trw-miniprogram-client.ts` +- **响应适配**:`packages/server/src/sandbox/trw-deploy-adapter.ts`(TRW Job → 旧 MCP envelope) +- **CodeBuddy / Stateful MCP**:`stateful-mcp-client.ts` +- **OpenCode CloudBase MCP**:`publishMiniprogram.ts`、`getDeployJobStatus.ts` +- **小程序开关**:`STATEFUL_MINIPROGRAM_FEATURE=true`(TRW 镜像需 `ENABLE_VIBECODING`) +- **Git 归档**:OVC 配 `GIT_ARCHIVE_*`;TRW 容器配同名变量 + `ENABLE_GIT_ARCHIVE` diff --git a/packages/server/scripts/debug-start-instance.ts b/packages/server/scripts/debug-start-instance.ts index 741be2b..2ec6ac3 100644 --- a/packages/server/scripts/debug-start-instance.ts +++ b/packages/server/scripts/debug-start-instance.ts @@ -4,6 +4,12 @@ import { config } from 'dotenv' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' +import { buildGitArchiveInstanceEnv } from '../src/sandbox/git-archive.js' +import { describeStatefulToolCustomConfiguration } from '../src/sandbox/ensure-stateful-tool.js' +import { + mergeInstanceEnvIntoToolConfiguration, + pickStartCustomConfigurationFromTool, +} from '../src/sandbox/stateful-custom-configuration.js' const here = dirname(fileURLToPath(import.meta.url)) config({ path: resolve(here, '../.env') }) @@ -38,12 +44,25 @@ async function main() { console.log('=== Tool CustomConfiguration ===') console.log(JSON.stringify(tool?.CustomConfiguration, null, 2)) - console.log('\n=== StartSandboxInstance ===') + 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<string, unknown> | undefined + if (toolCfg && withGitEnv && instanceEnv.length > 0) { + customConfiguration = mergeInstanceEnvIntoToolConfiguration(toolCfg, instanceEnv) + } else if (toolCfg && withMergedCfg) { + customConfiguration = pickStartCustomConfigurationFromTool(toolCfg) + } const resp = await callAgs('StartSandboxInstance', { ToolId: toolId, Timeout: '30m', AuthMode: 'NONE', + ...(customConfiguration ? { CustomConfiguration: customConfiguration } : {}), }) console.log('OK:', JSON.stringify(resp, null, 2)) } catch (err) { diff --git a/packages/server/scripts/stop-stateful-instances.ts b/packages/server/scripts/stop-stateful-instances.ts index 69824af..d18e155 100644 --- a/packages/server/scripts/stop-stateful-instances.ts +++ b/packages/server/scripts/stop-stateful-instances.ts @@ -48,13 +48,33 @@ async function callAgsManagerApi(action: string, param: Record<string, unknown>) return (await agsService.request(action, param)) as Record<string, unknown> } -async function main() { - const toolId = process.env.STATEFUL_TOOL_ID || process.env.STATEFUL_SANDBOX_TOOL_ID || '' - if (!toolId) { - console.error('Set STATEFUL_TOOL_ID in packages/server/.env') - process.exit(1) +async function resolveToolId(): Promise<string> { + 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 packages/server/.env') } + const { statefulToolNameForEnv } = await import('../src/sandbox/ensure-stateful-tool.js') + const toolName = statefulToolNameForEnv(envId) + const resp = await callAgsManagerApi('DescribeSandboxToolList', { + Filters: [{ Name: 'ToolName', Values: [toolName] }], + Limit: 20, + }) + const set = (resp.SandboxToolSet || (resp.data as Record<string, unknown> | undefined)?.SandboxToolSet) as + | Array<Record<string, unknown>> + | 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 callAgsManagerApi('DescribeSandboxInstanceList', { ToolId: toolId, Limit: 100 }) const data = list?.data as Record<string, unknown> | undefined const rows = (list?.InstanceSet || data?.InstanceSet || []) as Array<Record<string, unknown>> diff --git a/packages/server/src/agent/cloudbase-agent.service.ts b/packages/server/src/agent/cloudbase-agent.service.ts index bad1fed..a65ee62 100644 --- a/packages/server/src/agent/cloudbase-agent.service.ts +++ b/packages/server/src/agent/cloudbase-agent.service.ts @@ -18,7 +18,12 @@ import type { import { archiveToGit } from '../sandbox/git-archive.js' import { getCodingSystemPrompt } from './coding-mode.js' import { getDb } from '../db/index.js' -import { STATEFUL_WORKSPACE_ROOT, 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' @@ -565,10 +570,7 @@ export class CloudbaseAgentService { // Remote TRW workspace path (semantic only on stateful; tools run in sandbox via MCP). const workspaceCwd = cwd || resolvedCwd // CodeBuddy SDK runs on the OVC host — never mkdir/query against /home/user on macOS. - const localCwd = - workspaceCwd === STATEFUL_WORKSPACE_ROOT || workspaceCwd.startsWith('/home/user') - ? path.join(os.tmpdir(), 'ovc-agent', conversationId) - : workspaceCwd + const localCwd = resolveAgentHostCwd(workspaceCwd, conversationId) console.log(`[Agent] sandboxConfig: workspaceCwd=${workspaceCwd}, localCwd=${localCwd}, cwd=${cwd ?? '(none)'}`) mkdirSync(localCwd, { recursive: true }) @@ -787,10 +789,7 @@ export class CloudbaseAgentService { wrappedCallback({ type: 'agent_phase', phase, phaseToolName: toolName }) } - // 首个 phase:准备阶段(沙箱启动、工作空间初始化、历史恢复全部发生在 query() 之前) - emitPhase('preparing') - - // 沙箱子阶段 → emitPhase('preparing', 'sandbox:xxx') 桥接 + // 沙箱子阶段 → emitPhase('preparing', 'sandbox:xxx') 桥接(勿先发无 toolName 的 preparing,否则会盖住复用/启动等细粒度文案) // 让前端 AgentStatusIndicator 能在长耗时的沙箱创建流程中持续看到细粒度进度 const sandboxProgressBridge: SandboxProgressCallback = ({ phase }) => { emitPhase('preparing', `sandbox:${phase}`) @@ -1100,7 +1099,7 @@ export class CloudbaseAgentService { conversationId, userContext.envId, userContext.userId, - workspaceCwd, + localCwd, ) historicalMessages = restored.messages lastRecordId = restored.lastRecordId @@ -1212,7 +1211,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 走远程 TRW。 + const appendPromptOpts = { + remoteToolsActive: !!toolOverrideConfig, + localHostCwd: localCwd, + } // 多模态 prompt:有图片时构建 ContentBlock[] 作为 UserMessage,否则直接用字符串 let queryPrompt: string | any @@ -1257,8 +1260,22 @@ export class CloudbaseAgentService { append: isCodingMode ? getCodingSystemPrompt(userContext.envId, publishableKey) + '\n\n' + - buildAppendPrompt(workspaceCwd, conversationId, userContext.envId, sandboxConfig.sandboxMode, true) - : buildAppendPrompt(workspaceCwd, conversationId, userContext.envId, sandboxConfig.sandboxMode, false), + buildAppendPrompt( + workspaceCwd, + conversationId, + userContext.envId, + sandboxConfig.sandboxMode, + true, + appendPromptOpts, + ) + : buildAppendPrompt( + workspaceCwd, + conversationId, + userContext.envId, + sandboxConfig.sandboxMode, + false, + appendPromptOpts, + ), }, mcpServers, abortController, @@ -1896,7 +1913,7 @@ export class CloudbaseAgentService { userContext.userId, historicalMessages, lastRecordId, - workspaceCwd, + localCwd, assistantMessageId, isResumeFromInterrupt, preSavedUserRecordId, diff --git a/packages/server/src/agent/coding-mode.ts b/packages/server/src/agent/coding-mode.ts index 81faf39..6fbba42 100644 --- a/packages/server/src/agent/coding-mode.ts +++ b/packages/server/src/agent/coding-mode.ts @@ -53,6 +53,11 @@ IMPORTANT: 页面需要做好 error 处理,显示出具体的错误堆栈信 - @cloudbase/js-sdk(云开发前端 SDK) </tech-stack> +<workspace-location> +项目代码在 **远程沙箱** 的 \`/home/user\`(或 prepare 返回的 workspace),不在本机 \`/tmp\` / \`ovc-agent\` 目录。 +用户问 pwd、列文件、统计文件数:必须用 **Bash/Read/Glob** 在远程执行后作答,禁止根据 SDK 本机会话目录猜测。 +</workspace-location> + <dev-rules> 1. 仅使用以上技术栈,除非用户明确要求,不要引入新框架或库。 2. 新组件放在 src/components/,新页面放在 src/pages/ 并在 src/App.tsx 注册路由。 diff --git a/packages/server/src/agent/runtime/base-runtime.ts b/packages/server/src/agent/runtime/base-runtime.ts index 0f52a88..6bc9a40 100644 --- a/packages/server/src/agent/runtime/base-runtime.ts +++ b/packages/server/src/agent/runtime/base-runtime.ts @@ -155,6 +155,44 @@ export async function getPublishableKey(envId: string): Promise<string> { } } +export interface BuildAppendPromptOptions { + /** CodeBuddy SDK session dir on the OVC host (not the user workspace). */ + localHostCwd?: string + /** Bash/Read/Write/Glob/Grep are routed to the remote TRW 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\`、\`ovc-agent\` 说成「当前工作目录」。` + : `- **本机 SDK 会话目录**:仅用于 CodeBuddy 落盘,**不是**用户工作区。禁止把 \`/tmp\`、\`/var/folders\`、\`ovc-agent\` 说成「当前工作目录」。` + return ` +<remote-workspace priority="highest"> +你已连接 **CloudBase 远程沙箱**(Stateful TRW)。用户项目只存在于沙箱 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\`、进程环境或未调用工具时的猜测回答工作区路径。 +- 若工具调用失败:说明沙箱/工具异常并请用户重试,**不要**编造本机临时目录。 +</remote-workspace>` + } + + return ` +<remote-workspace> +远程沙箱尚未连接或工具 override 未就绪。不要声称自己已在 \`/home/user\` 或本机临时目录中工作。 +若用户询问 pwd/文件列表:说明需等待沙箱就绪后再用 Bash 查询,勿用本机路径作答。 +</remote-workspace>` +} + /** * 构建通用 system prompt(任务分类 + CloudBase 指引 + 沙箱上下文) */ @@ -164,6 +202,7 @@ export function buildAppendPrompt( envId?: string, sandboxMode?: 'shared' | 'isolated', isCodingMode?: boolean, + promptOptions?: BuildAppendPromptOptions, ): string { const roleLine = isCodingMode ? '你是一个通用 AI 编程助手,同时具备腾讯云开发(CloudBase)能力。' @@ -186,6 +225,9 @@ export function buildAppendPrompt( 3) **自动化/定时类**("每天…"、"每周…"、"定期…") → 使用 cronTask 工具管理定时任务(见下面 cron-task 章节)。 +4) **工作区探查类**(pwd、当前目录、列文件、统计文件数、目录树、du/wc/find/ls) + → 必须使用 Bash / Read / Glob 在**远程沙箱**执行并据实回答;**禁止**用本机 SDK 临时目录或未调工具时的猜测作答。 + **不确定时优先问用户**:"你希望我直接写文案给你,还是做一个可访问的网页?",不要擅自升级为 2)。 </task-classification> ` @@ -224,11 +266,18 @@ Cron 表达式格式:分 时 日 月 周,例如 "0 20 * * *" 表示每天 20 </tools-extra-info>` 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} <sandbox-context> ${sandboxPreamble}工具默认在 Home: ${homeDir} 下执行 @@ -499,18 +548,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 55aa9b7..fc5ae6b 100644 --- a/packages/server/src/agent/runtime/opencode-acp-runtime.ts +++ b/packages/server/src/agent/runtime/opencode-acp-runtime.ts @@ -47,6 +47,7 @@ import { OpencodeMessageBuilder, findLastRecordIds, buildHistoryContextPrompt } import { BaseAgentRuntime } from './base-runtime.js' import type { SandboxInstance } from '../../sandbox/provider/types.js' import { archiveToGit } from '../../sandbox/git-archive.js' +import { resolveAgentHostCwd } from '../../lib/sandbox-config.js' import os from 'node:os' import path from 'node:path' import fs from 'node:fs' @@ -313,7 +314,6 @@ export class OpencodeAcpRuntime extends BaseAgentRuntime { let sandboxResult: Awaited<ReturnType<typeof this.setupSandbox>> | null = null if (envId) { - await emit({ type: 'agent_phase', phase: 'preparing' }) sandboxResult = await this.setupSandbox({ conversationId, envId, @@ -335,12 +335,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 } diff --git a/packages/server/src/lib/sandbox-config.ts b/packages/server/src/lib/sandbox-config.ts index 82d7c63..d06ade7 100644 --- a/packages/server/src/lib/sandbox-config.ts +++ b/packages/server/src/lib/sandbox-config.ts @@ -2,6 +2,8 @@ * Sandbox config — TRW 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' @@ -43,6 +45,18 @@ export function normalizeSandboxCwd(cwd: string | null | undefined): string { return cwd } +/** + * Host path for CodeBuddy SDK session JSONL (hash(cwd) under ~/.codebuddy/projects). + * TRW 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(), 'ovc-agent', conversationId) + } + return workspaceCwd +} + export function resolveSandboxConfig(params: ResolveParams): SandboxConfig { const sandboxCwd = normalizeSandboxCwd(params.sandboxCwd) const sandboxMode = normalizeSandboxMode(params.sandboxMode) diff --git a/packages/server/src/middleware/mcp/cloudbase/README.md b/packages/server/src/middleware/mcp/cloudbase/README.md index c6fe5e9..35abf0e 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 | TRW `POST /api/jobs/miniprogram-deploy` | +| `getDeployJobStatus.ts` | `getDeployJobStatus`(新增) | **[CORE]** augment | TRW `GET /api/jobs/:jobId` | | `getDeployJobStatus.ts` | `getDeployJobStatus`(新增) | **[CORE]** augment | 查询小程序部署状态 | | `cronTask.ts` | `cronTask`(新增) | **[CORE]** augment | 本地 DB + cron-scheduler | diff --git a/packages/server/src/middleware/mcp/cloudbase/getDeployJobStatus.ts b/packages/server/src/middleware/mcp/cloudbase/getDeployJobStatus.ts index 82cdad4..a0d62c4 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 中硬编码(DEPLOY_STATUS_*)。 + * Poll TRW: 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..4b8b380 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 TRW 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/tasks.ts b/packages/server/src/routes/tasks.ts index 779e8f2..137d104 100644 --- a/packages/server/src/routes/tasks.ts +++ b/packages/server/src/routes/tasks.ts @@ -2934,12 +2934,13 @@ tasksRouter.get('/:taskId/files/download', requireUserEnv, async (c) => { const cleanup = () => runCommandInSandbox(sandbox!, `rm -f '${tmpZip}'`).catch(() => {}) try { + // Run from workspace root: zip -r .tmp/archive.zip <dir> (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) } diff --git a/packages/server/src/sandbox/ensure-stateful-tool.ts b/packages/server/src/sandbox/ensure-stateful-tool.ts index ce548b2..5a0cbf3 100644 --- a/packages/server/src/sandbox/ensure-stateful-tool.ts +++ b/packages/server/src/sandbox/ensure-stateful-tool.ts @@ -166,6 +166,65 @@ async function readStoredToolId(envId: string, userId?: string, taskId?: string) return null } +/** Tool template CustomConfiguration (Image, Ports, Probe, …). */ +export async function describeStatefulToolCustomConfiguration(toolId: string): Promise<Record<string, unknown> | 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<string, unknown>) : null +} + +/** Compare AGS tool Image with env-resolved URI; UpdateSandboxTool + warmup when drifted. */ +async function reconcileStatefulToolImageIfDrift( + toolId: string, + onProgress?: SandboxProgressCallback, + knownConfig?: Record<string, unknown> | null, +): Promise<void> { + 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<void> { const db = getDb() const provisionMode = await getProvisionMode() @@ -186,6 +245,7 @@ async function persistToolId(envId: string, toolId: string, userId?: string, tas /** * 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, @@ -203,7 +263,12 @@ export async function ensureStatefulTool( phase: 'template_resolve', message: '使用已登记的沙箱模板...\n', }) - return existing + 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) @@ -215,6 +280,7 @@ export async function ensureStatefulTool( message: '绑定已有沙箱模板...\n', }) await persistToolId(envId, byName, opts?.userId, opts?.taskId) + await reconcileStatefulToolImageIfDrift(byName, opts?.onProgress) return byName } diff --git a/packages/server/src/sandbox/git-archive.ts b/packages/server/src/sandbox/git-archive.ts index f24ae50..5232762 100644 --- a/packages/server/src/sandbox/git-archive.ts +++ b/packages/server/src/sandbox/git-archive.ts @@ -64,6 +64,49 @@ export function isGitArchiveConfigured(): boolean { return !!(config?.repo && config?.token) } +/** + * TRW git archive vars for PUT /api/workspace/env or workspace/init `env`. + * Do not pass via StartSandboxInstance CustomConfiguration.Env — boot-time + * ENABLE_GIT_ARCHIVE blocks /health and fails AGS port binding. + */ +export function buildGitArchiveWorkspaceEnv(): Record<string, string> { + const env: Record<string, string> = {} + 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 +} + +/** Debug-only: AGS CustomConfiguration.Env array shape. */ +export function buildGitArchiveInstanceEnv(): Array<{ Name: string; Value: string }> { + return Object.entries(buildGitArchiveWorkspaceEnv()).map(([Name, Value]) => ({ Name, Value })) +} + +export async function injectGitArchiveWorkspaceEnv(sandbox: SandboxInstance): Promise<void> { + const env = buildGitArchiveWorkspaceEnv() + if (!Object.keys(env).length) return + + const res = await sandbox.request('/api/workspace/env', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(env), + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`Git archive workspace env injection failed: ${res.status} ${text.slice(0, 200)}`) + } + const data = (await res.json().catch(() => null)) as { success?: boolean; error?: string } | null + if (data && data.success === false) { + throw new Error(data.error || 'Git archive workspace env injection failed') + } +} + /** * 将沙箱中的变更推送到 Git 归档仓库 * @@ -94,13 +137,14 @@ export async function archiveToGit( 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}`) + console.warn('[GitArchive] Push failed') } } catch (err) { console.error('[GitArchive] Error:', (err as Error)?.message) diff --git a/packages/server/src/sandbox/provider/stateful-provider.ts b/packages/server/src/sandbox/provider/stateful-provider.ts index ff709dd..f1a7150 100644 --- a/packages/server/src/sandbox/provider/stateful-provider.ts +++ b/packages/server/src/sandbox/provider/stateful-provider.ts @@ -38,6 +38,7 @@ import type { ToolOverrideHosting, } from './types.js' import { STATEFUL_WORKSPACE_ROOT } from '../../lib/sandbox-config.js' +import { buildGitArchiveWorkspaceEnv, injectGitArchiveWorkspaceEnv } from '../git-archive.js' import { ensureStatefulTool, resolveStatefulGatewayUrl } from '../ensure-stateful-tool.js' import { startStatefulInstanceWithWarmup } from '../stateful-tool-warmup.js' import { buildDataPlaneHeaders, TRW_SERVICE_PORT } from '../stateful/gateway.js' @@ -443,6 +444,12 @@ class StatefulProvider implements SandboxProvider { cacheKey: key, }) + try { + await injectGitArchiveWorkspaceEnv(inst) + } catch (err) { + console.warn('[StatefulProvider] Git archive workspace env injection failed:', (err as Error).message) + } + this.instanceCache.set(key, inst) onProgress?.({ phase: 'ready', message: '沙箱已就绪\n' }) return inst @@ -473,6 +480,7 @@ class StatefulProvider implements SandboxProvider { ...(ctx.credentials.sessionToken ? { TENCENTCLOUD_SESSIONTOKEN: ctx.credentials.sessionToken } : {}), INTEGRATION_IDE: 'codebuddy', WORKSPACE_FOLDER_PATHS: ctx.workspaceHint || STATEFUL_WORKSPACE_ROOT, + ...buildGitArchiveWorkspaceEnv(), }, }), signal: AbortSignal.timeout(PREPARE_INIT_TIMEOUT_MS), 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..a8eec08 --- /dev/null +++ b/packages/server/src/sandbox/stateful-custom-configuration.ts @@ -0,0 +1,44 @@ +/** + * AGS treats StartSandboxInstance / UpdateSandboxTool CustomConfiguration as a full + * object — passing only `{ Env }` drops Ports/Probe and causes port binding failures. + * Describe returns 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<string, unknown>, +): Record<string, unknown> { + return pickStartCustomConfigurationFields(toolCustomConfiguration) +} + +function pickStartCustomConfigurationFields(source: Record<string, unknown>): Record<string, unknown> { + const out: Record<string, unknown> = {} + 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<string, unknown>, + instanceEnv: StatefulEnvVar[], +): Record<string, unknown> { + const base = pickStartCustomConfigurationFields(toolCustomConfiguration) + if (!instanceEnv.length) return base + return { ...base, Env: instanceEnv } +} diff --git a/packages/server/src/sandbox/stateful/stateful-mcp-client.ts b/packages/server/src/sandbox/stateful/stateful-mcp-client.ts index cdcbed3..c0a5320 100644 --- a/packages/server/src/sandbox/stateful/stateful-mcp-client.ts +++ b/packages/server/src/sandbox/stateful/stateful-mcp-client.ts @@ -7,7 +7,8 @@ * - POST /api/tools/{tool} tool execution * - mcporter in vibecoding image (/opt/cloudbase-mcp) * - * Shared workspace at /home/user (no scope headers). Miniprogram jobs: STATEFUL_MINIPROGRAM_FEATURE. + * Shared workspace at /home/user (no scope headers). + * Miniprogram: POST /api/jobs/miniprogram-deploy (STATEFUL_MINIPROGRAM_FEATURE=true). */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' @@ -19,7 +20,13 @@ import { nanoid } from 'nanoid' import cron from 'node-cron' import type { McpClientBundle, McpDeps } from '../provider/types.js' -import { adaptDeployJobStatus, adaptMiniprogramDeployStart } from '../trw-deploy-adapter.js' +import { buildGitArchiveWorkspaceEnv } from '../git-archive.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' @@ -141,6 +148,64 @@ function serializeFnCall(toolName: string, args: Record<string, unknown>): strin const MINIPROGRAM_FEATURE_ENABLED = (process.env.STATEFUL_MINIPROGRAM_FEATURE || '').toLowerCase() === 'true' +async function resolveMiniprogramPrivateKey( + appId: string, + getMpDeployCredentials: McpDeps['getMpDeployCredentials'], +): Promise<string | undefined> { + 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<string, unknown>, + http: (path: string, init?: RequestInit) => Promise<Response>, + 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<Response>, +) { + const polled = await pollTrwMiniprogramJob(http, jobId) + return mcpTextPayload(JSON.stringify(polled.body ?? { error: true, status: polled.httpStatus })) +} + export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientBundle> { const { sandbox, @@ -186,6 +251,7 @@ export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientB TENCENTCLOUD_SESSIONTOKEN: creds.sessionToken ?? '', INTEGRATION_IDE: 'codebuddy', WORKSPACE_FOLDER_PATHS: workspaceFolderPaths, + ...buildGitArchiveWorkspaceEnv(), }), }) if (res.status === 401 || res.status === 403) throw new AuthRequiredError(res.status) @@ -363,93 +429,10 @@ export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientB async (args: Record<string, unknown>) => { if (!MINIPROGRAM_FEATURE_ENABLED) return miniprogramDegradedResponse() try { - let privateKey: string | undefined - const appId = args.appId as string - if (getMpDeployCredentials) { - const creds = await getMpDeployCredentials(appId) - if (creds) privateKey = creds.privateKey - } - if (!privateKey) { - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - error: true, - message: `未找到 appId ${appId} 的部署密钥,请先在小程序管理中关联该 appId`, - }), - }, - ], - isError: true, - } - } - const res = await sandbox.request('/api/jobs/miniprogram-deploy', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - appid: appId, - privateKey, - action: args.action, - projectPath: args.projectPath, - version: args.version, - description: args.description, - robot: args.robot, - }), - signal: AbortSignal.timeout(120_000), - }) - const rawBody = (await res.json().catch(() => null)) as unknown - if (!res.ok && res.status !== 202) { - const r = (rawBody ?? {}) as Record<string, unknown> - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - error: true, - status: res.status, - message: (r.error as string | undefined) || (r.message as string | undefined) || `HTTP ${res.status}`, - }), - }, - ], - isError: true, - } - } - const body = adaptMiniprogramDeployStart(res.status, rawBody) - if (body.async) { - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - async: true, - jobId: body.jobId, - message: '部署仍在进行中,请稍后使用 getDeployJobStatus 工具查询结果', - }), - }, - ], - } - } - if (!body.success) { - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - error: true, - message: body.error || (body.result as { errMsg?: string } | undefined)?.errMsg || 'Deploy failed', - result: body.result, - }), - }, - ], - isError: true, - } - } - return { content: [{ type: 'text' as const, text: JSON.stringify(body) }] } - } catch (e: any) { - return { - content: [{ type: 'text' as const, text: JSON.stringify({ error: true, message: e.message }) }], - isError: true, - } + 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) } }, ) @@ -461,19 +444,10 @@ export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientB async (args: Record<string, unknown>) => { if (!MINIPROGRAM_FEATURE_ENABLED) return miniprogramDegradedResponse({ jobId: args.jobId }) try { - const res = await sandbox.request(`/api/jobs/${encodeURIComponent(args.jobId as string)}`, { - signal: AbortSignal.timeout(30_000), - }) - const rawBody = (await res.json().catch(() => null)) as unknown - const body = res.ok && rawBody ? adaptDeployJobStatus(rawBody) : { error: true, status: res.status } - return { - content: [{ type: 'text' as const, text: JSON.stringify(body) }], - } - } catch (e: any) { - return { - content: [{ type: 'text' as const, text: JSON.stringify({ error: true, message: e.message }) }], - isError: true, - } + 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) } }, ) @@ -538,48 +512,14 @@ export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientB async (args: Record<string, unknown>) => { if (!MINIPROGRAM_FEATURE_ENABLED) return miniprogramDegradedResponse() try { - let privateKey: string | undefined - const appId = args.appId as string - if (getMpDeployCredentials) { - const creds = await getMpDeployCredentials(appId) - if (creds) privateKey = creds.privateKey - } - if (!privateKey) { - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ error: true, message: `未找到 appId ${appId} 的部署密钥` }), - }, - ], - isError: true, - } - } - const res = await sandbox.request('/api/jobs/miniprogram-deploy', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - appid: appId, - privateKey, - action: args.action, - projectPath: args.projectPath, - version: args.version, - description: args.description, - robot: args.robot, - }), - signal: AbortSignal.timeout(120_000), - }) - const rawBody = (await res.json().catch(() => null)) as unknown - if (!res.ok && res.status !== 202) { - return { - content: [{ type: 'text' as const, text: JSON.stringify({ error: true, status: res.status }) }], - isError: true, - } - } - const body = adaptMiniprogramDeployStart(res.status, rawBody) - return { content: [{ type: 'text' as const, text: JSON.stringify(body) }] } - } catch (e: any) { - return { content: [{ type: 'text' as const, text: `Error: ${e.message}` }], isError: true } + 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) } }, ), @@ -593,16 +533,10 @@ export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientB async (args: Record<string, unknown>) => { if (!MINIPROGRAM_FEATURE_ENABLED) return miniprogramDegradedResponse({ jobId: args.jobId }) try { - const res = await sandbox.request(`/api/jobs/${encodeURIComponent(args.jobId as string)}`, { - signal: AbortSignal.timeout(30_000), - }) - const rawBody = (await res.json().catch(() => null)) as unknown - const body = res.ok && rawBody ? adaptDeployJobStatus(rawBody) : { error: true, status: res.status } - return { - content: [{ type: 'text' as const, text: JSON.stringify(body) }], - } - } catch (e: any) { - return { content: [{ type: 'text' as const, text: `Error: ${e.message}` }], isError: true } + 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) } }, ), diff --git a/packages/server/src/sandbox/task-sandbox.ts b/packages/server/src/sandbox/task-sandbox.ts index 9c36979..f9e4e18 100644 --- a/packages/server/src/sandbox/task-sandbox.ts +++ b/packages/server/src/sandbox/task-sandbox.ts @@ -40,7 +40,11 @@ export async function getTaskSandbox( const existing = provider.getExisting ? await provider.getExisting(acquireCtx) : null if (existing) return existing - if (!options?.allowCreate) return null + + // 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) { 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..266e52c --- /dev/null +++ b/packages/server/src/sandbox/trw-miniprogram-client.ts @@ -0,0 +1,121 @@ +/** + * TRW vibecoding miniprogram deploy HTTP client (shared by Stateful MCP + OpenCode middleware). + * + * TRW 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<Response> + +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<string, unknown> + 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 TRW Job / 202 body to legacy MCP envelope. */ +export async function startTrwMiniprogramDeploy( + http: TrwHttp, + params: MiniprogramDeployRequest, +): Promise<MiniprogramDeployOutcome> { + 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/web/src/components/chat/agent-status-indicator.tsx b/packages/web/src/components/chat/agent-status-indicator.tsx index f034ff6..f12e93c 100644 --- a/packages/web/src/components/chat/agent-status-indicator.tsx +++ b/packages/web/src/components/chat/agent-status-indicator.tsx @@ -51,6 +51,8 @@ const PHASE_CONFIG: Record<AgentPhaseName, PhaseConfig> = { return '沙箱模板生成中(本环境仅首次)...' case 'sandbox:template_warmup': return '沙箱模板预热中...' + case 'sandbox:template_update': + return '同步沙箱模板镜像...' case 'sandbox:instance_reuse_session': return '复用本会话沙箱连接...' case 'sandbox:instance_reuse_shared': diff --git a/packages/web/src/components/file-browser.tsx b/packages/web/src/components/file-browser.tsx index 0372b6b..66549ad 100644 --- a/packages/web/src/components/file-browser.tsx +++ b/packages/web/src/components/file-browser.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { File, Folder, @@ -418,6 +418,7 @@ export function FileBrowser({ }, [ branchName, taskId, + sandboxId, onFilesLoaded, viewMode, setState, @@ -654,6 +655,23 @@ export function FileBrowser({ [taskId, viewMode, currentViewData, setState], ) + const prevSandboxIdRef = useRef<string | null | undefined>(sandboxId) + + useEffect(() => { + const prev = prevSandboxIdRef.current + prevSandboxIdRef.current = sandboxId ?? null + if (!prev && sandboxId) { + setState((prevState) => ({ + ...prevState, + [viewMode]: { + ...((prevState[viewMode as ViewModeKey] as ViewModeData | undefined) ?? currentViewData), + fetchAttempted: false, + error: null, + }, + })) + } + }, [sandboxId, viewMode, setState, currentViewData]) + useEffect(() => { if ((hasBranch || sandboxId) && files.length === 0 && !loading && !fetchAttempted) { fetchBranchFiles() @@ -794,14 +812,32 @@ export function FileBrowser({ }, []) const handleDownload = useCallback( - (filePath: string) => { + async (filePath: string, options?: { asZip?: boolean }) => { const url = `/api/tasks/${taskId}/files/download?path=${encodeURIComponent(filePath)}` - const a = document.createElement('a') - a.href = url - a.download = filePath.split('/').pop() || filePath - document.body.appendChild(a) - a.click() - document.body.removeChild(a) + try { + const response = await fetch(url, { credentials: 'include' }) + if (!response.ok) { + const data = (await response.json().catch(() => ({}))) as { error?: string } + throw new Error(data.error || 'Download failed') + } + const blob = await response.blob() + const disposition = response.headers.get('Content-Disposition') + let filename = filePath.split('/').pop() || 'download' + if (options?.asZip && !filename.endsWith('.zip')) filename = `${filename}.zip` + const match = disposition?.match(/filename\*?=(?:UTF-8''|")?([^";]+)"?/i) + if (match?.[1]) filename = decodeURIComponent(match[1]) + const objectUrl = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = objectUrl + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(objectUrl) + } catch (err) { + console.error('Download failed:', err) + toast.error(err instanceof Error ? err.message : 'Download failed') + } }, [taskId], ) @@ -1094,7 +1130,7 @@ export function FileBrowser({ <Clipboard className="w-4 h-4 mr-2" /> Paste<DropdownMenuShortcut>{isMac ? '⌘V' : 'Ctrl+V'}</DropdownMenuShortcut> </DropdownMenuItem> - <DropdownMenuItem onClick={() => handleDownload(fullPath)}> + <DropdownMenuItem onClick={() => handleDownload(fullPath, { asZip: true })}> <Download className="w-4 h-4 mr-2" /> Download as zip </DropdownMenuItem> diff --git a/packages/web/src/components/task-chat.tsx b/packages/web/src/components/task-chat.tsx index 5de841e..4fa84c1 100644 --- a/packages/web/src/components/task-chat.tsx +++ b/packages/web/src/components/task-chat.tsx @@ -1050,7 +1050,7 @@ export function TaskChat({ {!readOnly && isLatestGroup && isLatestMessage && - isStreamingResponse && + (isStreamingResponse || isSending) && agentPhase?.phase && agentPhase.phase !== 'idle' && ( <div className="px-2 pb-1"> @@ -1064,7 +1064,13 @@ export function TaskChat({ ) && (task.status === 'processing' || task.status === 'pending') ? ( <div className="opacity-50"> - <div className="italic">Generating response...</div> + {agentPhase?.phase && + agentPhase.phase !== 'idle' && + !readOnly && + isLatestGroup && + isLatestMessage ? null : ( + <div className="italic">Generating response...</div> + )} <div className="text-right font-mono opacity-70 mt-1"> {formatDuration(group.userMessage.createdAt)} </div> From 6c443586f15e3f02f08a1d33567f4c7c1ce6cba2 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Mon, 25 May 2026 21:01:45 +0800 Subject: [PATCH 13/29] docs: align README/setup with stateful; add CloudRun deploy Restore ttyd terminal, simplify env templates, document workspace/env git injection and branch vs upstream. Add deploy.mjs, cloudrun-deploy.md, and @cloudbase/manager-node for pnpm deploy:cloud. Co-authored-by: Cursor <cursoragent@cursor.com> --- .env.example | 111 ++--------- README.md | 188 +++++------------- docs/architecture.md | 6 +- docs/cloudrun-deploy.md | 39 ++++ docs/setup.md | 15 +- docs/trw-api-alignment.md | 2 +- docs/upstream-fork.md | 7 +- package.json | 2 + packages/server/.env.example | 28 +++ .../sandbox/stateful-custom-configuration.ts | 6 +- packages/web/src/components/logs-pane.tsx | 1 + packages/web/src/components/terminal.tsx | 41 +++- pnpm-lock.yaml | 3 + scripts/deploy.mjs | 186 +++++++++++++++++ 14 files changed, 376 insertions(+), 259 deletions(-) create mode 100644 docs/cloudrun-deploy.md create mode 100644 packages/server/.env.example create mode 100755 scripts/deploy.mjs diff --git a/.env.example b/.env.example index 0d66356..673e615 100644 --- a/.env.example +++ b/.env.example @@ -1,109 +1,28 @@ -# ============================================================ -# .env.example - 环境变量模板 -# 复制为 .env.local 后填入实际值,或直接运行 ./init.sh 自动生成 -# ============================================================ +# Root template — run ./init.sh to generate .env.local and packages/server/.env +# See README「本地启动」and docs/setup.md for details. -# ==================== Required ==================== - -# Session 加密密钥(init.sh 会自动生成) -# 32 bytes as base64 (openssl rand -base64 32). Do NOT reuse ENCRYPTION_KEY hex here. +# ==================== Required (init generates) ==================== JWE_SECRET= ENCRYPTION_KEY= - -# ==================== Auth ==================== - -# 认证方式:local / github / cloudbase(逗号分隔可多选) NEXT_PUBLIC_AUTH_PROVIDERS=local -# ==================== CloudBase ==================== - -# 腾讯云 API 密钥(访问管理 → API 密钥管理) +# ==================== CloudBase (also copied to packages/server/.env) ==================== TCB_SECRET_ID= TCB_SECRET_KEY= - -# CloudBase 环境 ID # TCB_ENV_ID= - -# 区域,默认 ap-shanghai # TCB_REGION=ap-shanghai -# CloudBase 用户环境(与下面 SANDBOX_INSTANCE_MODE 不是一回事) -# TCB_PROVISION_MODE=shared # shared | isolated | task — 见 README「两套共享/隔离」 -# SANDBOX_INSTANCE_MODE=shared # shared | isolated — 沙箱实例是否跨任务复用(packages/server/.env) - -# ==================== CodeBuddy Auth ==================== - -# 方式一:API Key(推荐,个人用户可直接使用) -# 获取地址:https://copilot.tencent.com/profile/ -# CODEBUDDY_API_KEY= -# CODEBUDDY_INTERNET_ENVIRONMENT=internal - -# 方式二:OAuth(企业旗舰版) -# CODEBUDDY_CLIENT_ID= -# CODEBUDDY_CLIENT_SECRET= -# CODEBUDDY_OAUTH_ENDPOINT=https://copilot.tencent.com/oauth2/token - -# ==================== Rate Limiting ==================== - -MAX_MESSAGES_PER_DAY=50 -MAX_SANDBOX_DURATION=300 - -# ==================== Stateful sandbox (feature/stateful-infra) ==================== -# Local dev: Node 22.x only (see README / docs/setup.md — better-sqlite3 native ABI). -# Copy server secrets to packages/server/.env — see .plans/stateful-env-checklist.md - -# TCB_API_KEY= # gateway JWT (sandbox apikey create) -# STATEFUL_TOOL_ID= # DEBUG ONLY — skips DB/CreateTool; normal flow uses ToolName ovc-{TCB_ENV_ID} -# STATEFUL_SANDBOX_IMAGE= # CreateSandboxTool + 已有 SDT 漂移时自动 UpdateSandboxTool;不配则用公开 TCR 默认或 TCR_IMAGE -# 公开默认见 packages/server/src/sandbox/stateful-vibecoding-image.ts(团队公开 ns,非你的密钥) -# 自管镜像示例: ccr.ccs.tencentyun.com/<your-namespace>/tcb-sandbox-ags:<YYMMDD-HHMM-xxxx-vibecoding> -# STATEFUL_SANDBOX_IMAGE_TAG= # URI 无 :tag 时补全(默认与代码常量一致) -# OVC_PUBLIC_TCR_REPO= # 仅当公开 ns 下仓库名不是 tcb-sandbox-public-cbe88d 时覆盖 -# STATEFUL_GATEWAY_URL= # optional; default https://{TCB_ENV_ID}.api.tcloudbasegateway.com/v1/sandbox/- -# STATEFUL_SANDBOX_ID= # optional: pin a running instance (debug) -# STATEFUL_MINIPROGRAM_FEATURE=true # when TRW exposes /api/jobs/miniprogram-deploy - -# Vite 原生错误 overlay 开关(创建沙箱时注入,需要重建沙箱生效) -# 设为 false 可关闭预览中 Vite 自带的全屏错误遮罩,改由平台侧 banner 展示构建错误 -# VITE_DEV_OVERLAY=false - -# ==================== TCR ==================== - -# TCR 容器镜像(pnpm setup:tcr → .env.local;可同步到 packages/server/.env 作 STATEFUL_SANDBOX_IMAGE) -# TCR_NAMESPACE= # 你的 ccr 命名空间,非公开 ns -# TCR_PASSWORD= # 勿提交 git -# TCR_IMAGE= # ccr.ccs.tencentyun.com/<namespace>/tcb-sandbox-ags:<tag> - -# ==================== GitHub OAuth (Optional) ==================== - -# GITHUB_CLIENT_ID= -# GITHUB_CLIENT_SECRET= - -# ==================== Git push(两套独立能力,建议用两个不同仓库验收) ==================== -# -# Archive(团队归档)— 配下面三项即可;不用在 UI Link。 -# 何时推:agent 一轮结束、编辑器保存文件、删 task 等(TRW /api/extend/git_push) -# 验收:server 日志 [GitArchive] Push completed;只看 GIT_ARCHIVE_REPO 对应远端 -# -# Link(用户仓库)— 配 GIT_PERSONAL_AUTH;仓库 URL+分支在任务菜单「Link Git Repository」里填(不进 env)。 -# 何时推:Link 成功后,agent 一轮结束(沙箱内 git push) -# 验收:只看你在 UI 里 Link 的那个库(例如 tcb_test_link),与归档库无关 -# -# 改 GIT_ARCHIVE_* 或实例级 env 后:重启 server,并停旧 AGS 实例再起新实例。 - -# --- Archive:平台统一备份仓(勿与 Link 测试库混用) --- -# GIT_ARCHIVE_REPO=https://github.com/<org>/<archive-repo>.git -# GIT_ARCHIVE_USER= -# GIT_ARCHIVE_TOKEN= - -# --- Link:推用户自有仓库用的凭证(仓库地址在 UI Link,不写 env) --- -# GIT_PERSONAL_AUTH=ghp_... # 需对 Link 的库有 push 权限;可与 ARCHIVE token 相同 PAT,但 remote 必须是两个库 - -# ==================== Proxy (Optional) ==================== +# ==================== Optional ==================== +# TCB_PROVISION_MODE=shared # shared | isolated | task — user CloudBase env +# MAX_MESSAGES_PER_DAY=50 +# MAX_SANDBOX_DURATION=300 -# http_proxy= +# CodeBuddy: CODEBUDDY_API_KEY or OAuth trio — see init / README +# GitHub OAuth: GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET -# ==================== Mino (Optional) ==================== +# TCR (pnpm setup:tcr): TCR_NAMESPACE, TCR_PASSWORD, TCR_IMAGE -# MiMo openai-compatible proxy API key (used by opencode-acp runtime) -# MIMO_API_KEY= +# ==================== packages/server/.env (not this file) ==================== +# Runtime secrets live in packages/server/.env — init writes TCB_*, TCB_API_KEY, +# CODEBUDDY_*, SANDBOX_INSTANCE_MODE, optional GIT_ARCHIVE_* / GIT_PERSONAL_AUTH. +# Do NOT set STATEFUL_TOOL_ID or STATEFUL_SANDBOX_ID unless debugging scripts. diff --git a/README.md b/README.md index 0064694..f5fa974 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,10 @@ ## 延伸阅读 -- [Setup 指南](docs/setup.md) — 初始化流程、环境变量、验证清单与排障 +- [Setup 指南](docs/setup.md) — 初始化流程、验证清单与排障 - [系统架构](docs/architecture.md) — 系统分层、模块设计与关键数据流 +- [TRW API 对齐](docs/trw-api-alignment.md) — OVC 调用的 TRW 路由与开关 +- [CloudRun 部署](docs/cloudrun-deploy.md) — 云托管一键部署与控制台 env - [上游分叉与同步](docs/upstream-fork.md) — 硬分叉基线、merge 历史、下次同步命令 --- @@ -81,7 +83,10 @@ ├── docs/ │ ├── setup.md # setup 详解与排障 │ ├── architecture.md # 系统架构文档 -│ └── scf-session-sharing.md # (历史)SCF Session 共享,stateful 分支已废弃 +│ ├── trw-api-alignment.md # OVC ↔ TRW 路由 +│ ├── cloudrun-deploy.md # 云托管部署 +│ ├── upstream-fork.md # 上游分叉与同步 +│ └── scf-session-sharing.md # (历史)SCF,已废弃 ├── packages/ │ ├── web/ # React 19 + Vite 前端 │ ├── server/ # Hono 后端:Auth、Agent 编排、Sandbox 管理 @@ -89,64 +94,61 @@ │ └── shared/ # ACP 协议类型、任务 / 消息 schema ├── scripts/ │ ├── init.mjs # 交互式初始化脚本 +│ ├── deploy.mjs # 云托管一键部署 │ └── setup-tcr.mjs # TCR 镜像仓库配置 └── init.sh # 快速入口 ``` --- -## 快速开始 +## 本地启动(最小路径) -**前置条件** - -开始前请确认: -- **Node.js 22.x**(必需;`better-sqlite3` 原生模块与 server build target 对齐 **node22**,勿用 Node 23+ 或 brew 默认 Node 26)。推荐:`mise use node@22` 或 `nvm use`(根目录 `.nvmrc` 为 `22`) -- pnpm 10+ -- Docker 已安装并启动(stateful 分支本地 dev **不**需要本机 TRW;沙箱在云端) -- 已准备 CloudBase 环境和腾讯云 API 密钥 -- 已准备 CodeBuddy API Key 或 OAuth 配置 - -**一键初始化** +**前置**:Node **22.x**(见 `.nvmrc`)、pnpm 10+、CloudBase 支撑环境 + API 密钥、CodeBuddy API Key。本地 **不**跑 TRW 容器,编码沙箱在云端 AGS + TRW。 ```bash git clone <repository-url> -cd coding-agent-template -./init.sh +cd OpenVibeCoding +./init.sh # 生成 .env.local + packages/server/.env +pnpm dev # Web http://localhost:5174 API http://localhost:3001/api ``` -初始化脚本依次完成:Node.js 检查 → pnpm 安装 → `.env.local` 生成 → Docker 检查 → CloudBase 配置 → 依赖安装 → CodeBuddy 认证 → TCR 配置 → 数据库初始化。 +**`packages/server/.env` 最少要有**(`init.sh` 会写入大部分): + +| 变量 | 作用 | +| --- | --- | +| `JWE_SECRET` / `ENCRYPTION_KEY` | 会话与敏感字段加密 | +| `TCB_ENV_ID` / `TCB_SECRET_ID` / `TCB_SECRET_KEY` | CloudBase 管理面(环境、沙箱 Tool/实例) | +| `TCB_API_KEY` | 数据面 gateway 访问 TRW | +| `CODEBUDDY_API_KEY` | Agent(或 OAuth 一套,见 init) | + +首次创建任务时,平台会按 `ovc-{TCB_ENV_ID}` 自动 **CreateSandboxTool** 并写入 DB,**无需**配置 `STATEFUL_TOOL_ID`。沙箱镜像默认用代码内置公开 TCR;自研 TRW 镜像再设 `STATEFUL_SANDBOX_IMAGE` 或 `pnpm setup:tcr`。 -详细步骤与排障见 [docs/setup.md](docs/setup.md)。 +排障与完整清单:[docs/setup.md](docs/setup.md)。Server 模板:[packages/server/.env.example](packages/server/.env.example)。 --- ## 开发 ```bash -pnpm dev # 同时启动 web (localhost:5174) 和 server (localhost:3001) -pnpm dev:web # 仅启动前端 -pnpm dev:server # 仅启动后端 +pnpm dev # web :5174 + server :3001 +pnpm dev:web +pnpm dev:server ``` ## 生产(本机) ```bash -pnpm build # 构建所有包 -pnpm start # 启动生产服务(端口 3001,同时服务 API 和静态文件) +pnpm build +pnpm start # :3001,API + 静态资源 ``` ## 云托管(CloudRun) -OVC 以**容器**部署到 CloudBase 云托管:根目录 `Dockerfile` 构建前后端一体镜像,监听 **80**。 - ```bash -tcb env use <TCB_ENV_ID> -tcb cloudrun deploy -e <TCB_ENV_ID> -s <服务名> --port 80 --source . --force +pnpm deploy:cloud # 需 init 后的 TCB_*;云端构建 Dockerfile ``` -构建日志与访问地址在控制台「云托管 → 服务详情 → 部署」。环境变量在**同一服务的「服务设置 → 环境变量」**配置(见下节),不要依赖把 `packages/server/.env` 打进镜像(`.dockerignore` 已排除 `.env*`)。 - -更细的初始化与排障见 [docs/setup.md](docs/setup.md)。 +控制台补全与 `ASK_USER_BASE_URL` 等见 [docs/cloudrun-deploy.md](docs/cloudrun-deploy.md)。 ## 常用命令 @@ -171,114 +173,30 @@ pnpm opencode:setup # 配置 OpenCode provider 和模型 --- -## 环境变量 - -配置文件分工: +## 环境变量(精简) | 文件 | 用途 | | --- | --- | -| `.env.local` | 根目录;`init.sh` 写入 `JWE_SECRET`、`ENCRYPTION_KEY`、`NEXT_PUBLIC_AUTH_PROVIDERS` 等 | -| `packages/server/.env` | **Server 运行时**(本地 `pnpm dev` / `pnpm start` 读这里;云托管在控制台填同等变量) | - -更多字段说明见 [docs/setup.md](docs/setup.md)。 - -### 沙箱 infra · Tool 模板(`STATEFUL_TOOL_ID` 要不要配?) - -**结论:日常开发/上线都不要配 `STATEFUL_TOOL_ID`。** 它只用于调试或运维脚本(`describe-stateful-tool.ts` 等)。 - -代码里 **Tool 名称是固定的**,由支撑环境 ID 推导: - -```text -ToolName = ovc-{TCB_ENV_ID} # 非法字符会替换为 -,最长 48 字符 -``` - -例如 `TCB_ENV_ID=<your-support-env-id>` → Tool 名 `ovc-<your-support-env-id>`(非法字符会替换为 `-`)。 - -**正常解析顺序**(`ensureStatefulTool`): - -1. ~~`STATEFUL_TOOL_ID` 环境变量~~ — **仅调试**,会跳过 DB 与创建逻辑,不应写入 `.env` -2. **数据库** — `shared` 模式:`settings.stateful_tool_id`;`isolated` / `task`:`user_resources.statefulToolId` -3. **按名绑定** — 沙箱控制面 `DescribeSandboxToolList`,匹配 `ToolName`,写回 DB -4. **首次创建** — 尚无记录时 `CreateSandboxTool`,使用上述 `ToolName`,再把返回的 `sdt-xxx` 写入 DB - -因此:本地第一次跑任务时会创建 Tool 并落库;换机器只要连同一套 CloudBase DB,就会复用同一个 `sdt-xxx`,**不必**在 `.env` 里手写 ToolId。若 DB 被清空但平台上仍有同名 Tool,会先按 `ToolName` 查询再写回 DB。 - -### 两套「共享 / 隔离」别混 +| `.env.local` | 根目录会话/认证;`init.sh` 生成 | +| `packages/server/.env` | **运行时主配置**(本地 dev/start;云托管在控制台填同名变量) | -| 维度 | 环境变量 / 配置 | 管什么 | `shared` | `isolated` | -| --- | --- | --- | --- | --- | -| **CloudBase 用户环境** | `TCB_PROVISION_MODE`(init 或 `/admin/settings`) | 用户是否共用**支撑环境**、是否预建 `user_resources` | 全员共用 `TCB_ENV_ID` | 每用户独立 env + CAM | -| **沙箱实例** | `SANDBOX_INSTANCE_MODE`(`packages/server/.env` 或 DB `sandbox_instance_mode`) | **运行时容器**是否跨任务复用 | 同一支撑 env 下多任务共用一个实例 | 每任务独立实例(复用该任务的 `sandboxId`) | +### 可选能力(按需开启) -- 本地试 OVC 沙箱行为:改 **`SANDBOX_INSTANCE_MODE`** 即可(`shared` / `isolated`),与是否多租户无关。 -- **`TCB_PROVISION_MODE=task`** 时新建任务默认实例模式倾向 `isolated`(见 `sandbox-config.ts`),仍可用 Admin 或 env 覆盖。 -- 优先级(实例模式):DB `sandbox_instance_mode` → env `SANDBOX_INSTANCE_MODE` → 内置默认 `shared`。 -- UI 进度文案:`shared` 会出现「复用环境沙箱(多任务共享)」;`isolated` 为「复用任务沙箱」/「为当前任务启动沙箱实例」。 - -### 沙箱镜像(`STATEFUL_SANDBOX_IMAGE`) - -沙箱 infra 首次创建 Tool(`CreateSandboxTool`)需要 **腾讯云 TCR 个人版** 完整 URI(`ccr.ccs.tencentyun.com/<namespace>/<repo>:<tag>`),不能填 Docker Hub / GHCR 直链。 - -**解析顺序**(`resolveStatefulSandboxImage`,见 `stateful-vibecoding-image.ts`): - -1. `packages/server/.env` 的 **`STATEFUL_SANDBOX_IMAGE`** -2. 同文件或根目录 `.env.local` 的 **`TCR_IMAGE`**(`pnpm setup:tcr` 写入) -3. **代码内置默认**(团队公开 TCR,开箱首次建 Tool): - `ccr.ccs.tencentyun.com/tcb-sandbox-public-cbe88d/tcb-sandbox-public-cbe88d:<tag>` - 默认 tag 与常量 `VIBECODING_PUBLIC_TCR_DEFAULT_TAG` 同步(当前为带时间的 `…-vibecoding` 后缀,非 `latest`)。 - -**用你自己的 TCR 镜像(推荐自部署 / 定制 TRW 时)** - -1. 在 [腾讯云 TCR 个人版](https://console.cloud.tencent.com/tcr) 创建命名空间,`docker login ccr.ccs.tencentyun.com`(用户名一般为账号 UIN)。 -2. 构建 TRW vibecoding 镜像后推送,tag 建议一条龙格式:`YYMMDD-HHMM-<随机>-vibecoding`(见仓库外 `code_sandbox/一条龙.md` § Tag & Push)。 -3. 写入 **`packages/server/.env`**(云托管写控制台同等变量): - -```bash -# 二选一即可;显式优先 -STATEFUL_SANDBOX_IMAGE=ccr.ccs.tencentyun.com/<your-namespace>/tcb-sandbox-ags:<your-tag> -# 或跑 pnpm setup:tcr 后使用生成的 TCR_IMAGE(会同步到 server .env) -``` - -也可只写无 tag 的路径,由 `STATEFUL_SANDBOX_IMAGE_TAG` 补默认 tag。首次建 Tool 成功后,镜像 URI 已绑在沙箱 Tool 模板上,**之后可删掉 env**(仍靠 DB / `ovc-{TCB_ENV_ID}` 复用 Tool)。 - -> **隐私**:勿把 `TCB_SECRET_*`、`TCB_API_KEY`、`CODEBUDDY_API_KEY`、个人 TCR 密码等写入 README 或提交 git;`packages/server/.env` 已在 `.gitignore`。 - -### 本地开发(`pnpm dev`) - -| 变量 | 必需 | 说明 | -| --- | --- | --- | -| `JWE_SECRET` / `ENCRYPTION_KEY` | 是 | 会话与敏感字段加密(`init.sh` 可生成) | -| `TCB_ENV_ID` | 是 | 支撑环境 ID | -| `TCB_SECRET_ID` / `TCB_SECRET_KEY` | 是 | 管理面:建环境、沙箱 Tool/实例、provision | -| `TCB_API_KEY` | 是 | 数据面:gateway 访问 TRW(CloudBase 控制台创建 API Key) | -| `CODEBUDDY_API_KEY` 或 OAuth 一套 | 是 | Agent 调用 | -| `STATEFUL_SANDBOX_IMAGE` | 首次 `CreateSandboxTool` | 见上节;不配则用公开 TCR 默认或 `TCR_IMAGE` | -| `TCR_IMAGE` | 自管镜像时 | `pnpm setup:tcr` 推到**你的**命名空间后写入 | -| `SANDBOX_INSTANCE_MODE` | 否 | `shared`(默认)/ `isolated` — **沙箱实例**是否跨任务复用 | -| `TCB_PROVISION_MODE` | 否 | `shared` / `isolated` / `task` — **CloudBase 用户环境**隔离粒度 | -| `DB_PROVIDER` | 否 | 默认 `cloudbase`;本地纯离线可 `drizzle`(SQLite) | -| `PORT` | 否 | 默认 `3001` | -| `ASK_USER_BASE_URL` | 否 | 默认 `http://127.0.0.1:${PORT}`,OpenCode 子进程回调用 | - -**不要配(除非调试)**:`STATEFUL_TOOL_ID`、`STATEFUL_SANDBOX_ID`(固定实例)、`STATEFUL_GATEWAY_URL`(默认 `https://{TCB_ENV_ID}.api.tcloudbasegateway.com/v1/sandbox/-`)。 - -可选:`GITHUB_*`、`GIT_ARCHIVE_*`、`STATEFUL_MINIPROGRAM_FEATURE`、`STATEFUL_TOOL_WARMUP_*` 等见 [docs/setup.md](docs/setup.md)。 - -### 云托管(CloudRun) +| 目的 | 变量 | +| --- | --- | +| 每任务独立沙箱实例 | `SANDBOX_INSTANCE_MODE=isolated`(默认 `shared`) | +| 每用户独立 CloudBase 环境 | `TCB_PROVISION_MODE=isolated` 或 `task` | +| 自研 TRW 镜像 | `STATEFUL_SANDBOX_IMAGE` 或 `TCR_IMAGE`(`pnpm setup:tcr`) | +| 任务结束推归档仓 | `GIT_ARCHIVE_REPO` + `GIT_ARCHIVE_TOKEN`(+ `GIT_ARCHIVE_USER`) | +| UI Link 用户仓库 | `GIT_PERSONAL_AUTH` + 任务内 Link | +| 小程序部署 MCP | `STATEFUL_MINIPROGRAM_FEATURE=true`(TRW 需 vibecoding preset) | +| OpenCode 回调公网地址 | `ASK_USER_BASE_URL`(云托管必填公网 URL) | -与本地 **同一套变量名**,在控制台配置;差异主要是运行形态: +**不要写入 `.env`(仅运维脚本调试)**:`STATEFUL_TOOL_ID`、`STATEFUL_SANDBOX_ID`、`STATEFUL_GATEWAY_URL`(有默认值)。 -| 变量 | 云托管注意点 | -| --- | --- | -| `PORT` | 必须为 **80**(与 `Dockerfile` / `--port 80` 一致) | -| `NODE_ENV` | `production` | -| `JWE_SECRET` / `ENCRYPTION_KEY` | 与本地相同密钥体系,**勿**每次部署随机换(否则已有 session 失效) | -| `TCB_*` / `TCB_API_KEY` / `CODEBUDDY_*` | 与本地相同;Secret 走控制台「环境变量」,不要打进镜像 | -| `ASK_USER_BASE_URL` | 必须设为 **公网可访问的 OVC 根 URL**(如 `https://<云托管默认域名>`),不能依赖默认的 `127.0.0.1` | -| `STATEFUL_SANDBOX_IMAGE` | 首次在该环境创建 Tool 时需要;之后靠 DB 里的 `stateful_tool_id` | -| `STATEFUL_TOOL_ID` | **不要配**(多副本共用 DB 时也应走 DB + ToolName 逻辑) | +**Git 归档注入**:`GIT_ARCHIVE_*` 在沙箱 **启动成功后** 经 `PUT /api/workspace/env` 写入,不在 `StartSandboxInstance` 传 boot Env(避免探活失败)。详见 [docs/trw-api-alignment.md](docs/trw-api-alignment.md)。 -云托管不跑 `pnpm dev`:无 Vite 代理,浏览器只访问容器内的 80 端口(静态 + API 一体)。 +沙箱 Tool 名 `ovc-{TCB_ENV_ID}`,ID 存 DB;镜像漂移时自动 `UpdateSandboxTool`(保留 Ports/Probe)。两套「共享/隔离」说明见 [docs/setup.md](docs/setup.md#沙箱实例模式sandbox_instance_mode)。 --- @@ -469,13 +387,13 @@ CODEBUDDY_USE_CUSTOM_MODELS=true **硬分叉基线**(不变):`43c3e6038d833481c2fd0d4d206f4a801de7a750`(2026-05-21,`feautre/env-pool` 合入点)。本线此后增加沙箱 infra,与上游 **不保证长期可 merge**。 -**最近一次上游同步**(2026-05-21):`git merge origin/main`,已对齐至上游 `main` 的 `a878ddb`(CodeBuddy TokenHub、自定义模型、`codebuddy-setup`、agent 选项等)。完整记录见 [docs/upstream-fork.md](docs/upstream-fork.md)。 +**上游同步**(2026-05-21):已 merge 至 `a878ddb`。此后上游 `main` 另有 CloudRun `pnpm deploy:cloud` 等提交,本线已引入 `scripts/deploy.mjs`;**尚未** merge `origin/main` 最新(见 [docs/upstream-fork.md](docs/upstream-fork.md))。 -| | 上游 `main`(已 merge 部分) | 本线独有(`feature/stateful-infra`) | -| -------- | ---------------------------- | ---------------------------------------- | -| Agent | TokenHub、自定义模型、选项更新 | 同左(已并入) | -| Sandbox | 环境池 / 演进中 | 沙箱 infra(Stateful + TRW)、公开 TCR 默认 | -| 实例策略 | shared / isolated / task | + `SANDBOX_INSTANCE_MODE`、细粒度进度文案 | +| | 本线 `feature/stateful-infra` 侧重 | +| -------- | ------------------------------------ | +| Sandbox | AGS Stateful + TRW、公开 TCR 默认、workspace/env 注入 Git | +| 实例策略 | `SANDBOX_INSTANCE_MODE`、复用/启动进度文案 | +| 部署 | `pnpm deploy:cloud` + [docs/cloudrun-deploy.md](docs/cloudrun-deploy.md) | --- diff --git a/docs/architecture.md b/docs/architecture.md index f0b8eff..87ec771 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -275,9 +275,9 @@ flowchart LR ### Stateful Sandbox Lifecycle 1. **Ensure Tool** — `ensureStatefulTool(envId)` 为环境创建或复用沙箱 Tool 模板(`sdt-xxx`),镜像来自 `STATEFUL_SANDBOX_IMAGE` → `TCR_IMAGE` → 公开 TCR 代码默认 -2. **Acquire Instance** — `SANDBOX_INSTANCE_MODE`:`shared` 每 env 单实例;`isolated` 每 task。`StatefulSandboxProvider`:running 复用、paused 恢复、缺失则 `StartSandboxInstance`;`stateful-tool-warmup` 轮询预热 +2. **Acquire Instance** — `SANDBOX_INSTANCE_MODE`:`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 访问 TRW -4. **Init Workspace** — `PUT /api/workspace/env` 注入凭证,`POST /api/workspace/init` 初始化 `/home/user` +4. **Init Workspace** — 实例健康后 `PUT /api/workspace/env`(凭证 + 可选 `GIT_ARCHIVE_*`),`POST /api/workspace/init` 初始化 `/home/user` 5. **Execute** — Agent 工具经 TRW `/api/tools/*`;CloudBase MCP 由 server 侧 `stateful-mcp-client` 转发 6. **Archive** — 任务结束时 Git 归档工作区 @@ -300,7 +300,7 @@ flowchart LR | Bash | `/api/tools/bash` | Shell 命令执行 | | Web Terminal | `/preview/7681/` (ttyd) | 浏览器终端 | | Vite preview | `/preview/5173/` | 开发服务器(默认端口 5173) | -| Git Push | `POST /api/extend/git_push` | 工作区推送到远端(TRW `ENABLE_GIT_ARCHIVE` + OVC `GIT_ARCHIVE_*`) | +| Git Push | `POST /api/extend/git_push` | 工作区推送到远端(OVC 经 workspace/env 注入 `GIT_ARCHIVE_*` / `ENABLE_GIT_ARCHIVE`) | | Miniprogram deploy | `POST /api/jobs/miniprogram-deploy` | 启动部署 job(TRW `ENABLE_VIBECODING`) | | Job poll | `GET /api/jobs/:jobId` | 轮询 job 状态 / 日志 | | Health | `/health` | 实例健康检查 | diff --git a/docs/cloudrun-deploy.md b/docs/cloudrun-deploy.md new file mode 100644 index 0000000..ac21696 --- /dev/null +++ b/docs/cloudrun-deploy.md @@ -0,0 +1,39 @@ +# CloudRun 部署(云托管) + +OVC 以根目录 `Dockerfile` 构建**前后端一体**容器,监听 **80**。环境变量在控制台配置,**不要**把 `packages/server/.env` 打进镜像(`.dockerignore` 已排除)。 + +## 前置 + +- 已完成 `./init.sh`(或手动具备 `TCB_ENV_ID`、`TCB_SECRET_ID`、`TCB_SECRET_KEY`) +- `packages/server/.env` 中沙箱与 Agent 相关变量与本地一致(部署后在控制台再填一份) +- 已安装 CloudBase CLI:`npm i -g @cloudbase/cli` 且 `cloudbase login` + +## 一键部署 + +```bash +pnpm deploy:cloud +``` + +- 服务名默认:`vibecoding-platform` +- 云端从源码 + `Dockerfile` 构建,无需本机 Docker +- 构建进度:控制台 → 云托管 → 服务详情 → 部署记录 + +## 控制台必改项(相对本地) + +| 变量 | 值 | +| --- | --- | +| `PORT` | `80` | +| `NODE_ENV` | `production` | +| `ASK_USER_BASE_URL` | 云托管公网根 URL(如 `https://xxx.run.tcloudbase.com`),**不能**用 `127.0.0.1` | + +其余与本地 `packages/server/.env` 同名:`TCB_*`、`TCB_API_KEY`、`CODEBUDDY_*`、可选 `GIT_ARCHIVE_*` 等。勿配置 `STATEFUL_TOOL_ID`(多副本应走 DB + `ovc-{TCB_ENV_ID}` Tool 名)。 + +## 部署后验证 + +1. 打开控制台给出的默认域名,`GET /health` 为 ok +2. 登录并创建任务,确认沙箱进度与预览 +3. 若沙箱失败,对照 [docs/setup.md](./setup.md) 沙箱排障;本地可用 `pnpm --filter @coder/server stop:stateful-instances` + +## 与上游 main + +`scripts/deploy.mjs` 与上游 `TencentCloudBase/OpenVibeCoding` `main` 对齐;合并上游后优先保留本分支 stateful 相关 env 说明。 diff --git a/docs/setup.md b/docs/setup.md index 68c6cfa..8073a31 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -125,14 +125,13 @@ flowchart TD | 变量 | 必需 | 说明 | | --- | --- | --- | | `TCB_API_KEY` | 是 | gateway 数据面 Bearer(与 `TCB_ENV_ID` 配套) | -| `STATEFUL_TOOL_ID` | **否(仅调试)** | 跳过 DB/创建;正常由 Tool 名 `ovc-{TCB_ENV_ID}` + DB 解析,见 README | -| `STATEFUL_SANDBOX_IMAGE` | 首次 `CreateSandboxTool` | TCR 完整 URI;不配则用代码公开默认或 `TCR_IMAGE`(见 README) | -| `STATEFUL_SANDBOX_IMAGE_TAG` | 否 | URI 无 `:tag` 时补全 | -| `SANDBOX_INSTANCE_MODE` | 否 | `shared` / `isolated` — **沙箱实例**是否跨任务复用(写在 `packages/server/.env`) | -| `STATEFUL_GATEWAY_URL` | 否 | 默认 `https://{TCB_ENV_ID}.api.tcloudbasegateway.com/v1/sandbox/-` | -| `STATEFUL_SANDBOX_ID` | 否 | 调试时固定实例 ID | -| `STATEFUL_TOOL_WARMUP_POLL_MS` / `STATEFUL_TOOL_WARMUP_POLL_MAX` | 否 | 镜像更新后预热轮询(默认 10s × 6) | -| `TCR_IMAGE` | 自管镜像时 | `pnpm setup:tcr` 写入**你的**命名空间;可当作 `STATEFUL_SANDBOX_IMAGE` | +| `STATEFUL_SANDBOX_IMAGE` | 否 | 首次 `CreateSandboxTool` 或镜像漂移 reconcile;不配则用公开 TCR 默认或 `TCR_IMAGE` | +| `TCR_IMAGE` | 否 | `pnpm setup:tcr` 写入你的命名空间 | +| `SANDBOX_INSTANCE_MODE` | 否 | `shared` / `isolated` — 沙箱**实例**是否跨任务复用 | +| `GIT_ARCHIVE_*` / `GIT_PERSONAL_AUTH` | 否 | 归档与 Link;实例就绪后 `PUT /api/workspace/env` 注入,见 [trw-api-alignment.md](./trw-api-alignment.md) | +| `STATEFUL_MINIPROGRAM_FEATURE` | 否 | `true` 时启用 TRW 小程序 job API | + +**仅调试脚本**:`STATEFUL_TOOL_ID`、`STATEFUL_SANDBOX_ID`、`STATEFUL_GATEWAY_URL`(默认 gateway 可省略)。 **镜像**:须为 `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 密码。 diff --git a/docs/trw-api-alignment.md b/docs/trw-api-alignment.md index a46f8b8..fd63d0a 100644 --- a/docs/trw-api-alignment.md +++ b/docs/trw-api-alignment.md @@ -37,4 +37,4 @@ - **CodeBuddy / Stateful MCP**:`stateful-mcp-client.ts` - **OpenCode CloudBase MCP**:`publishMiniprogram.ts`、`getDeployJobStatus.ts` - **小程序开关**:`STATEFUL_MINIPROGRAM_FEATURE=true`(TRW 镜像需 `ENABLE_VIBECODING`) -- **Git 归档**:OVC 配 `GIT_ARCHIVE_*`;TRW 容器配同名变量 + `ENABLE_GIT_ARCHIVE` +- **Git 归档**:OVC 在实例就绪后 `PUT /api/workspace/env` 注入 `GIT_ARCHIVE_*`(勿在 `StartSandboxInstance` boot Env 传 `ENABLE_GIT_ARCHIVE`,会拖垮 `/health`) diff --git a/docs/upstream-fork.md b/docs/upstream-fork.md index d34168c..55622be 100644 --- a/docs/upstream-fork.md +++ b/docs/upstream-fork.md @@ -48,10 +48,11 @@ | `4669043` | Merge pull request #23(CodeBuddy TokenHub) | | `a878ddb` | feat: 更新 agent 选项 | -**当前对齐状态**(2026-05-21 merge 后): +**当前对齐状态**(2026-05-25): -- `git merge-base HEAD origin/main` → `a878ddb`(本分支已包含当时上游 `main` 全部历史) -- 本线 **独有** 提交仍在 `a878ddb` 之上(stateful-infra、沙箱进度、TCR 默认等) +- `git merge-base HEAD origin/main` → `a878ddb`(已 merge 的上游截止点) +- `origin/main` 在 `a878ddb` 之后另有提交(如 `pnpm deploy:cloud`、`scripts/deploy.mjs`、README CloudRun 章节);本线已通过 cherry-pick 引入 `deploy.mjs`,**尚未**完整 merge 最新 `main` +- 本线 **独有**:stateful 沙箱、git workspace/env 注入、TRW 对齐文档等(`0699323` 等) 下次看上游新提交: diff --git a/package.json b/package.json index c69b154..085e80b 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,11 @@ "setup:tcr": "node scripts/setup-tcr.mjs", "opencode:setup": "node scripts/opencode-setup.mjs", "codebuddy:setup": "node scripts/codebuddy-setup.mjs", + "deploy:cloud": "node scripts/deploy.mjs", "prepare": "husky" }, "devDependencies": { + "@cloudbase/manager-node": "^4.10.6", "@cloudbase/node-sdk": "^3.18.1", "@eslint/eslintrc": "^3.3.3", "@tailwindcss/postcss": "^4.1.18", diff --git a/packages/server/.env.example b/packages/server/.env.example new file mode 100644 index 0000000..4501d47 --- /dev/null +++ b/packages/server/.env.example @@ -0,0 +1,28 @@ +# Server runtime — copy to .env or run ./init.sh (writes this file). +# Local: pnpm dev:server loads via --env-file=.env + +# --- Required --- +PORT=3001 +JWE_SECRET= +ENCRYPTION_KEY= +TCB_ENV_ID= +TCB_SECRET_ID= +TCB_SECRET_KEY= +TCB_API_KEY= +CODEBUDDY_API_KEY= + +# --- Sandbox (AGS + TRW); first task may CreateSandboxTool once per env --- +# SANDBOX_INSTANCE_MODE=shared +# STATEFUL_SANDBOX_IMAGE= # optional; default public TCR in code if unset +# TCR_IMAGE= # from pnpm setup:tcr; used if STATEFUL_SANDBOX_IMAGE unset + +# --- Optional features --- +# TCB_PROVISION_MODE=shared +# GIT_ARCHIVE_REPO= / GIT_ARCHIVE_TOKEN= / GIT_ARCHIVE_USER= +# GIT_PERSONAL_AUTH= # Link Git in UI; separate test repo from archive +# STATEFUL_MINIPROGRAM_FEATURE=true +# ASK_USER_BASE_URL=http://127.0.0.1:3001 + +# --- Debug only (never in production) --- +# STATEFUL_TOOL_ID= +# STATEFUL_SANDBOX_ID= diff --git a/packages/server/src/sandbox/stateful-custom-configuration.ts b/packages/server/src/sandbox/stateful-custom-configuration.ts index a8eec08..d6295e1 100644 --- a/packages/server/src/sandbox/stateful-custom-configuration.ts +++ b/packages/server/src/sandbox/stateful-custom-configuration.ts @@ -1,7 +1,7 @@ /** - * AGS treats StartSandboxInstance / UpdateSandboxTool CustomConfiguration as a full - * object — passing only `{ Env }` drops Ports/Probe and causes port binding failures. - * Describe returns read-only fields (e.g. ImageDigest) that Start rejects. + * 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 } diff --git a/packages/web/src/components/logs-pane.tsx b/packages/web/src/components/logs-pane.tsx index d8e8913..59b879a 100644 --- a/packages/web/src/components/logs-pane.tsx +++ b/packages/web/src/components/logs-pane.tsx @@ -399,6 +399,7 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { taskId={task.id} isActive={activeTab === 'terminal' && !isCollapsed} isMobile={!isDesktop} + sandboxReady={!!task.sandboxId} /> </div> </div> diff --git a/packages/web/src/components/terminal.tsx b/packages/web/src/components/terminal.tsx index e6ffc8f..3a87092 100644 --- a/packages/web/src/components/terminal.tsx +++ b/packages/web/src/components/terminal.tsx @@ -1,10 +1,15 @@ -import { useState, useRef, useImperativeHandle, forwardRef } from 'react' +/** + * Web terminal via TRW ttyd — virtual port 7681, proxied as /api/tasks/:id/preview/7681/. + */ + +import { forwardRef, useImperativeHandle } from 'react' interface TerminalProps { taskId: string className?: string isActive?: boolean isMobile?: boolean + sandboxReady?: boolean } export interface TerminalRef { @@ -13,20 +18,36 @@ export interface TerminalRef { } export const Terminal = forwardRef<TerminalRef, TerminalProps>(function Terminal( - { taskId: _taskId, className: _className, isActive: _isActive, isMobile: _isMobile }, + { taskId, className, isActive, sandboxReady = true }, ref, ) { - const [text, setText] = useState('') - const containerRef = useRef<HTMLDivElement>(null) - useImperativeHandle(ref, () => ({ - clear: () => setText(''), - getTerminalText: () => text, + clear: () => {}, + getTerminalText: () => '', })) + if (!sandboxReady) { + return ( + <div + className={`h-full bg-black text-muted-foreground p-2 font-mono text-xs flex items-center justify-center ${className ?? ''}`} + > + 沙箱未就绪,终端暂不可用 + </div> + ) + } + + if (!isActive) { + return <div className={`h-full min-h-0 bg-black ${className ?? ''}`} /> + } + + const src = `/api/tasks/${taskId}/preview/7681/` + return ( - <div ref={containerRef} className="h-full bg-black text-green-400 p-2 font-mono text-xs overflow-y-auto"> - <div className="text-muted-foreground">Terminal (stub) - not yet connected</div> - </div> + <iframe + src={src} + title="Web Terminal" + className={`h-full min-h-0 w-full border-0 bg-black ${className ?? ''}`} + allow="clipboard-read; clipboard-write" + /> ) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63b73aa..8ce7a2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,9 @@ importers: .: devDependencies: + '@cloudbase/manager-node': + specifier: ^4.10.6 + version: 4.10.6 '@cloudbase/node-sdk': specifier: ^3.18.1 version: 3.18.1 diff --git a/scripts/deploy.mjs b/scripts/deploy.mjs new file mode 100755 index 0000000..67b0c5a --- /dev/null +++ b/scripts/deploy.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node + +/** + * Deploy Script — 一键部署到 CloudBase 云托管 + * + * Usage: + * pnpm deploy:cloud # 部署到云托管 + * pnpm deploy:cloud --skip-build # 跳过本地构建步骤(云端会重新构建) + * + * TODO: 云函数(镜像模式)部署暂未完成,存在以下问题: + * - CLI 无法正确传递 ImagePort 参数 + * - 平台默认 ImagePort=9000,需要镜像内 ENV PORT=9000 匹配 + * - 镜像冷启动时间过长(1.29GB),容易超过 InitTimeout + * 待平台侧修复后可重新启用 + */ + +import { execSync } from 'child_process' +import { createRequire } from 'module' +import { existsSync, readFileSync, writeFileSync } from 'fs' +import { resolve } from 'path' + +const require = createRequire(import.meta.url) +const CloudBase = require('@cloudbase/manager-node') + +// ===================== Constants ===================== + +const ROOT = process.cwd() +const ENV_FILE = resolve(ROOT, '.env.local') +const SERVER_ENV_FILE = resolve(ROOT, 'packages/server/.env') +const CLOUDBASERC = resolve(ROOT, 'cloudbaserc.json') +const DEFAULT_SERVICE_NAME = 'vibecoding-platform' + +// ===================== Helpers ===================== + +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + green: '\x1b[32m', + yellow: '\x1b[33m', + red: '\x1b[31m', + cyan: '\x1b[36m', + dim: '\x1b[2m', +} + +function log(message, type = 'info') { + const prefix = { + info: `${colors.cyan}→${colors.reset}`, + success: `${colors.green}✓${colors.reset}`, + error: `${colors.red}✗${colors.reset}`, + warn: `${colors.yellow}!${colors.reset}`, + }[type] + console.log(`${prefix} ${message}`) +} + +function logSection(title) { + console.log('') + console.log(`${colors.bright}${colors.cyan}━━━ ${title} ━━━${colors.reset}`) +} + +function loadEnvFile(filePath) { + const env = {} + if (existsSync(filePath)) { + readFileSync(filePath, 'utf-8').split('\n').forEach((line) => { + const trimmed = line.trim() + if (trimmed && !trimmed.startsWith('#')) { + const [key, ...rest] = trimmed.split('=') + if (key) env[key.trim()] = rest.join('=').trim() + } + }) + } + return env +} + +function commandExists(name) { + try { + execSync(`which ${name}`, { stdio: 'pipe' }) + return true + } catch { + return false + } +} + +function run(cmd, options = {}) { + console.log(` ${colors.dim}$ ${cmd}${colors.reset}`) + execSync(cmd, { stdio: 'inherit', cwd: ROOT, ...options }) +} + +// ===================== CloudBase SDK Helper ===================== + +function createCloudBaseApp(env) { + return new CloudBase({ + secretId: env.TCB_SECRET_ID, + secretKey: env.TCB_SECRET_KEY, + envId: env.TCB_ENV_ID, + }) +} + +// ===================== CloudRun Deploy ===================== + +async function deployCloudRun(env) { + logSection('部署到云托管(容器服务)') + + const envId = env.TCB_ENV_ID + if (!envId) { + log('缺少 TCB_ENV_ID,请先运行 ./init.sh', 'error') + process.exit(1) + } + + if (!commandExists('cloudbase')) { + log('cloudbase CLI 未安装,请先安装:npm i -g @cloudbase/cli', 'error') + process.exit(1) + } + + // Ensure cloudbaserc.json has envId so CLI can read it + const rcBackup = existsSync(CLOUDBASERC) ? readFileSync(CLOUDBASERC, 'utf-8') : null + const rcContent = { envId } + writeFileSync(CLOUDBASERC, JSON.stringify(rcContent, null, 2)) + + try { + // cloudbase cloudrun deploy uploads source + Dockerfile to cloud for building + // No local Docker required — cloud builds the image from Dockerfile + log('提交到云托管(云端构建)...') + run(`cloudbase cloudrun deploy -s ${DEFAULT_SERVICE_NAME} --port 80 --force --source .`) + } catch (err) { + log('部署失败', 'error') + log(`可在控制台手动部署:https://tcb.cloud.tencent.com/dev?envId=${envId}#/run`, 'info') + process.exit(1) + } finally { + if (rcBackup) writeFileSync(CLOUDBASERC, rcBackup) + } + + // Query service domain via CloudBase manager-node SDK + let accessUrl = '' + try { + const app = createCloudBaseApp(env) + const tcbr = app.commonService('tcbr') + const result = await tcbr.call({ + Action: 'DescribeCloudRunServerDetail', + Param: { EnvId: envId, ServerName: DEFAULT_SERVICE_NAME }, + }) + accessUrl = result.BaseInfo?.DefaultDomainName || '' + } catch { /* ignore — URL is optional */ } + + // Done + console.log('') + log('部署已提交,云端构建中...', 'success') + console.log('') + console.log(` ${colors.bright}服务:${colors.reset}${DEFAULT_SERVICE_NAME}`) + if (accessUrl) { + console.log(` ${colors.bright}访问地址:${colors.reset}${accessUrl}`) + } + console.log(` ${colors.bright}构建进度:${colors.reset}`) + console.log(` https://tcb.cloud.tencent.com/dev?envId=${envId}#/platform-run/service/detail?serverName=${DEFAULT_SERVICE_NAME}&tabId=deploy&envId=${envId}`) + console.log('') +} + +// ===================== Main ===================== + +async function main() { + console.log('') + console.log(`${colors.bright}${colors.cyan}━━━ 部署到 CloudBase 云托管 ━━━${colors.reset}`) + console.log('') + + const args = process.argv.slice(2) + + // Load env + const env = { ...loadEnvFile(ENV_FILE), ...loadEnvFile(SERVER_ENV_FILE) } + + if (!env.TCB_ENV_ID) { + log('未找到 TCB_ENV_ID,请先运行 ./init.sh 完成初始化', 'error') + process.exit(1) + } + + if (!env.TCB_SECRET_ID || !env.TCB_SECRET_KEY) { + log('未找到 TCB_SECRET_ID / TCB_SECRET_KEY,请先运行 ./init.sh 完成初始化', 'error') + process.exit(1) + } + + await deployCloudRun(env) +} + +main().catch((err) => { + console.error('') + log(`部署失败:${err.message}`, 'error') + process.exit(1) +}) From 95b1564abb75a8a271fd332b9a5d9887f001badd Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Mon, 25 May 2026 21:10:21 +0800 Subject: [PATCH 14/29] fix(web): file-browser setState for jotai partial update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jotai task atom setter accepts Partial<FileBrowserState>, not a React-style updater function — fixes Docker/web build TS2560. Co-authored-by: Cursor <cursoragent@cursor.com> --- packages/web/src/components/file-browser.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/web/src/components/file-browser.tsx b/packages/web/src/components/file-browser.tsx index 99a7197..510832b 100644 --- a/packages/web/src/components/file-browser.tsx +++ b/packages/web/src/components/file-browser.tsx @@ -661,14 +661,13 @@ export function FileBrowser({ const prev = prevSandboxIdRef.current prevSandboxIdRef.current = sandboxId ?? null if (!prev && sandboxId) { - setState((prevState) => ({ - ...prevState, + setState({ [viewMode]: { - ...((prevState[viewMode as ViewModeKey] as ViewModeData | undefined) ?? currentViewData), + ...currentViewData, fetchAttempted: false, error: null, }, - })) + }) } }, [sandboxId, viewMode, setState, currentViewData]) From 1f884307929be6d811227edf3902661f4385b4a7 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Mon, 25 May 2026 21:50:53 +0800 Subject: [PATCH 15/29] feat(server): stateful sandbox auth mode and docs alignment Add ENABLE_AUTH_MODE (default false) with required TCB_ACCESS_TOKEN when enabled: omit StartSandboxInstance AuthMode NONE, attach X-Access-Token on data-plane requests, and inject ENABLE_AUTH_MODE into workspace env only. Align docs and env templates (setup, .env.example, init), restore main OpenCode e2e scripts as SCF-legacy, remove obsolete trw-api-alignment doc, and rename TRW/OVC references to sandbox business image terminology. Co-authored-by: Cursor <cursoragent@cursor.com> --- .env.example | 100 +++++- CHANGELOG.md | 2 +- README-zh.md | 18 +- README.md | 7 +- docs/architecture.md | 42 +-- docs/cloudrun-deploy.md | 4 +- docs/scf-session-sharing.md | 2 +- docs/setup.md | 24 +- docs/trw-api-alignment.md | 40 --- docs/upstream-fork.md | 12 +- packages/server/.env.example | 8 +- .../server/scripts/debug-start-instance.ts | 8 +- packages/server/scripts/probe-gateway-port.ts | 8 +- .../test-opencode-coding-preview-e2e.mts | 317 ++++++++++++++++++ .../scripts/test-opencode-sandbox-e2e.mts | 154 +++++++++ .../scripts/update-stateful-tool-image.ts | 2 +- .../server/scripts/verify-stateful-e2e.ts | 9 +- .../src/agent/cloudbase-agent.service.ts | 8 +- packages/server/src/agent/coding-mode.ts | 6 +- .../server/src/agent/runtime/base-runtime.ts | 14 +- packages/server/src/lib/cloudbase-mcp.ts | 2 +- packages/server/src/lib/sandbox-config.ts | 16 +- .../src/middleware/mcp/cloudbase/README.md | 6 +- .../mcp/cloudbase/getDeployJobStatus.ts | 2 +- .../mcp/cloudbase/publishMiniprogram.ts | 2 +- packages/server/src/routes/tasks.ts | 10 +- .../src/sandbox/ensure-stateful-tool.ts | 4 +- packages/server/src/sandbox/git-archive.ts | 7 +- packages/server/src/sandbox/preview-proxy.ts | 4 +- .../server/src/sandbox/preview-ws-proxy.ts | 2 +- .../src/sandbox/provider/stateful-provider.ts | 92 +++-- .../src/sandbox/stateful-sandbox-auth.ts | 26 ++ .../src/sandbox/stateful-vibecoding-image.ts | 5 +- .../server/src/sandbox/stateful/gateway.ts | 22 +- .../sandbox/stateful/stateful-mcp-client.ts | 34 +- packages/server/src/sandbox/task-sandbox.ts | 4 +- packages/server/src/sandbox/tool-override.ts | 4 +- .../server/src/sandbox/trw-deploy-adapter.ts | 6 +- .../src/sandbox/trw-miniprogram-client.ts | 6 +- .../server/src/sandbox/wait-vite-ready.ts | 8 +- .../server/src/util/skill-loader-override.ts | 2 +- packages/web/src/components/terminal.tsx | 2 +- .../web/src/pages/admin/settings-page.tsx | 2 +- scripts/init.mjs | 8 +- 44 files changed, 809 insertions(+), 252 deletions(-) delete mode 100644 docs/trw-api-alignment.md create mode 100644 packages/server/scripts/test-opencode-coding-preview-e2e.mts create mode 100644 packages/server/scripts/test-opencode-sandbox-e2e.mts create mode 100644 packages/server/src/sandbox/stateful-sandbox-auth.ts diff --git a/.env.example b/.env.example index 7303b02..eefb655 100644 --- a/.env.example +++ b/.env.example @@ -1,28 +1,98 @@ -# Root template — run ./init.sh to generate .env.local and packages/server/.env -# See README「本地启动」and docs/setup.md for details. +# ============================================================ +# .env.example - 环境变量模板 +# 复制为 .env.local 后填入实际值,或直接运行 ./init.sh 自动生成 +# ============================================================ -# ==================== Required (init generates) ==================== +# ==================== Required ==================== + +# Session 加密密钥(init.sh 会自动生成) JWE_SECRET= ENCRYPTION_KEY= + +# ==================== Auth ==================== + +# 认证方式:local / github / cloudbase(逗号分隔可多选) NEXT_PUBLIC_AUTH_PROVIDERS=local -# ==================== CloudBase (also copied to packages/server/.env) ==================== +# ==================== CloudBase ==================== + +# 腾讯云 API 密钥(访问管理 → API 密钥管理) TCB_SECRET_ID= TCB_SECRET_KEY= + +# CloudBase 环境 ID # TCB_ENV_ID= + +# 区域,默认 ap-shanghai # TCB_REGION=ap-shanghai -# ==================== Optional ==================== -# TCB_PROVISION_MODE=shared # shared | isolated | task — user CloudBase env -# MAX_MESSAGES_PER_DAY=50 -# MAX_SANDBOX_DURATION=300 +# 用户环境模式:shared(默认)或 isolated +# TCB_PROVISION_MODE=shared + +# ==================== CodeBuddy Auth ==================== + +# 方式一:API Key(推荐,个人用户可直接使用) +# 获取地址:https://copilot.tencent.com/profile/ +# CODEBUDDY_API_KEY= +# CODEBUDDY_INTERNET_ENVIRONMENT=internal + +# 方式二:OAuth(企业旗舰版) +# CODEBUDDY_CLIENT_ID= +# CODEBUDDY_CLIENT_SECRET= +# CODEBUDDY_OAUTH_ENDPOINT=https://copilot.tencent.com/oauth2/token + +# ==================== Rate Limiting ==================== + +MAX_MESSAGES_PER_DAY=50 +MAX_SANDBOX_DURATION=300 + +# ==================== Sandbox ==================== + +# 沙箱实例隔离(AGS;与 TCB_PROVISION_MODE 不是一回事): +# shared - 同一 envId 下多任务共享实例 +# isolated - 每 task 独立实例(默认) +WORKSPACE_ISOLATION=isolated + +# 沙箱数据面 gateway(写入 packages/server/.env,init 会生成) +# TCB_API_KEY= + +# 实例鉴权(默认关闭;开启后必须配置 TCB_ACCESS_TOKEN,sit_*,用于 X-Access-Token) +# ENABLE_AUTH_MODE=false +# TCB_ACCESS_TOKEN= + +# 沙箱业务镜像 URI(可选;不配则用代码内公开 TCR 默认) +# STATEFUL_SANDBOX_IMAGE= + +# 仅调试:跳过按 env 自动创建 Tool / 固定实例 +# STATEFUL_TOOL_ID= +# STATEFUL_SANDBOX_ID= + +# Vite 原生错误 overlay 开关(创建沙箱时注入,需要重建沙箱生效) +# 设为 false 可关闭预览中 Vite 自带的全屏错误遮罩,改由平台侧 banner 展示构建错误 +# VITE_DEV_OVERLAY=false + +# ==================== TCR ==================== + +# TCR 容器镜像配置(由 pnpm setup:tcr 自动写入) +# TCR_NAMESPACE= +# TCR_PASSWORD= +# TCR_IMAGE= + +# ==================== GitHub OAuth (Optional) ==================== + +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= + +# ==================== Git Archive (Optional) ==================== + +# 工作区 Git 归档配置 +# GIT_ARCHIVE_REPO= +# GIT_ARCHIVE_USER= +# GIT_ARCHIVE_TOKEN= -# CodeBuddy: CODEBUDDY_API_KEY or OAuth trio — see init / README -# GitHub OAuth: GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET +# Link 用户自有仓库(仓库 URL 在任务 UI 填写) +# GIT_PERSONAL_AUTH= -# TCR (pnpm setup:tcr): TCR_NAMESPACE, TCR_PASSWORD, TCR_IMAGE +# ==================== Proxy (Optional) ==================== -# ==================== packages/server/.env (not this file) ==================== -# Runtime: TCB_*, TCB_API_KEY, CODEBUDDY_*, optional SANDBOX_INSTANCE_MODE, -# GIT_ARCHIVE_* / GIT_PERSONAL_AUTH. Template: packages/server/.env.example -# Do NOT set STATEFUL_TOOL_ID or STATEFUL_SANDBOX_ID unless debugging scripts. +# http_proxy= diff --git a/CHANGELOG.md b/CHANGELOG.md index 66a2f60..348c882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. ### Added -- **沙箱 infra(Stateful + TRW)**:`StatefulSandboxProvider` + `ensureStatefulTool` + TRW 数据面;移除 SCF `scf-sandbox-manager` / `sandbox-mcp-proxy`;预览经 OVC → gateway → TRW `/preview/5173/`;终端 ttyd `/preview/7681/`;镜像更新后 `stateful-tool-warmup` 轮询 +- **沙箱 infra(Stateful + 沙箱业务镜像)**:`StatefulSandboxProvider` + `ensureStatefulTool` + 沙箱业务镜像 数据面;移除 SCF `scf-sandbox-manager` / `sandbox-mcp-proxy`;预览经 OpenVibeCoding → gateway → 沙箱业务镜像 `/preview/5173/`;终端 ttyd `/preview/7681/`;镜像更新后 `stateful-tool-warmup` 轮询 ### Changed diff --git a/README-zh.md b/README-zh.md index 54671c6..d5bff40 100644 --- a/README-zh.md +++ b/README-zh.md @@ -46,7 +46,7 @@ | 基础设施 | 绑定特定平台 | 腾讯云 CloudBase(DB / Storage / Functions / CDN) | | Agent 引擎 | 内置单一模型 | CodeBuddy + OpenCode 双引擎,模型自由切换 | | 环境隔离 | 用户级隔离 | shared / isolated / task 三级隔离,支持多租户 | -| 沙箱 | 平台托管 | CloudBase AGS Stateful + TRW(TCR 镜像),gateway 数据面 | +| 沙箱 | 平台托管 | CloudBase AGS Stateful + 沙箱业务镜像(TCR 镜像),gateway 数据面 | | 云资源操作 | 无 / 有限 | MCP 工具直接操作 DB、存储、函数、域名 | | 部署目标 | 平台内托管 | Web CDN / 微信小程序 / 自定义域名 | | 人机协作 | 基础对话 | Plan 模式 + ToolConfirm 四值权限 + 内联提问表单 | @@ -61,7 +61,7 @@ | **双 Agent 引擎** | CodeBuddy 与 OpenCode 可选,各自独立模型列表,前端一键切换 | | **三级环境隔离** | shared(共用)/ isolated(用户独立)/ task(独立子账号),Admin 后台热切换,无需重启 | | **环境池预热** | 预创建 CloudBase 环境 + CAM + Policy,获取延迟从分钟级降至毫秒级;池空时自动回退实时创建 | -| **编码沙箱** | AGS Tool/实例;TRW 工作区 `/home/user`;预览 `/preview/5173`、终端 `/preview/7681` 经 OVC 反代 | +| **编码沙箱** | AGS Tool/实例;沙箱业务镜像 工作区 `/home/user`;预览 `/preview/5173`、终端 `/preview/7681` 经 OpenVibeCoding 反代 | | **实时预览** | 内嵌 Browser 工具栏(地址栏 / 导航 / 刷新);HMR 热更新;预览错误自动修复反馈 | | **CloudBase MCP** | 50+ 工具覆盖 DB、Storage、Functions、域名、安全规则,Agent 可直接操作云资源 | | **Human-in-Loop** | 工具执行四值确认(allow / always / deny / exit);内联提问表单,不打断对话上下文 | @@ -115,7 +115,9 @@ ## 本地启动(最小路径) -**前置**:Node **22.x**(`.nvmrc`)、pnpm 10+、CloudBase 支撑环境 + API 密钥、CodeBuddy API Key。本地不跑 TRW 容器,编码沙箱在云端 AGS + TRW。 +**前置**:Node **22.x**(`.nvmrc`)、pnpm 10+、CloudBase 支撑环境 + API 密钥、CodeBuddy 或 OpenCode API Key(`npm i -g opencode-ai`)。本地只跑 OpenVibeCoding 前后端;编码沙箱在云端 AGS + 沙箱业务镜像。 + +**Agent**:任务可选 **CodeBuddy** 或 **OpenCode**(`opencode-acp`),二者共用同一套 AGS 沙箱与沙箱业务镜像(`BaseAgentRuntime.setupSandbox`)。 ```bash git clone https://github.com/TencentCloudBase/OpenVibeCoding.git @@ -130,10 +132,11 @@ pnpm dev # Web http://localhost:5174 API http://localhost:3001/api | --- | --- | | `JWE_SECRET` / `ENCRYPTION_KEY` | 会话加密 | | `TCB_ENV_ID` / `TCB_SECRET_ID` / `TCB_SECRET_KEY` | 管理面 | -| `TCB_API_KEY` | TRW 数据面 gateway | -| `CODEBUDDY_API_KEY` | Agent | +| `TCB_API_KEY` | 沙箱业务镜像 数据面 gateway | +| `CODEBUDDY_API_KEY` | CodeBuddy Agent | +| (可选)本机 `opencode` / `opencode-ai` CLI | OpenCode Agent | -Tool 名 `ovc-{TCB_ENV_ID}` 自动创建/复用,勿配 `STATEFUL_TOOL_ID`。模板见 [packages/server/.env.example](packages/server/.env.example)。排障:[docs/setup.md](docs/setup.md)。 +Tool 名 `openvibecoding-{TCB_ENV_ID}` 自动创建/复用,勿配 `STATEFUL_TOOL_ID`。模板见 [packages/server/.env.example](packages/server/.env.example)。排障:[docs/setup.md](docs/setup.md)。 --- @@ -207,7 +210,6 @@ pnpm opencode:setup # 配置 OpenCode provider 和模型 ├── docs/ │ ├── setup.md # setup 详解与排障 │ ├── architecture.md # 系统架构文档 -│ ├── trw-api-alignment.md # OVC ↔ TRW 路由 │ ├── cloudrun-deploy.md # 云托管部署 │ ├── upstream-fork.md # 上游分叉与同步 │ └── scf-session-sharing.md # (历史)SCF @@ -233,7 +235,7 @@ pnpm opencode:setup # 配置 OpenCode provider 和模型 | 后端 | Hono, Node.js, Drizzle ORM | | 数据库 | CloudBase DB(主),SQLite(本地回退) | | AI | `@tencent-ai/agent-sdk` (CodeBuddy), OpenCode ACP | -| Sandbox | CloudBase AGS Stateful + TRW,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) | diff --git a/README.md b/README.md index e22e3e5..d62bfcc 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ An open-source alternative to [Lovable](https://lovable.dev) / [v0](https://v0.d | 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 + TRW (TCR images), gateway data plane | +| 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 | @@ -61,7 +61,7 @@ An open-source alternative to [Lovable](https://lovable.dev) / [v0](https://v0.d | **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; TRW at `/home/user`; preview `/preview/5173`, terminal ttyd `/preview/7681` via OVC proxy | +| **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 | | **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 | @@ -211,7 +211,6 @@ pnpm opencode:setup # Configure OpenCode provider and models ├── docs/ │ ├── setup.md # Setup walkthrough & troubleshooting │ ├── architecture.md # System architecture -│ ├── trw-api-alignment.md # OVC ↔ TRW routes │ ├── cloudrun-deploy.md # CloudRun deploy & env │ ├── upstream-fork.md # Fork baseline & sync │ └── scf-session-sharing.md # (legacy) SCF session sharing @@ -237,7 +236,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 AGS Stateful + TRW, TCR 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) | diff --git a/docs/architecture.md b/docs/architecture.md index 87ec771..4754d41 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ ## Overview -CloudBase VibeCoding Platform 是一个基于腾讯云 CloudBase 的 AI 编程助手平台。用户通过 Web 界面向 Agent 下达编程指令,Agent 在 **沙箱 infra**(Stateful 控制面 + TRW 数据面 + gateway 路由)中执行代码操作,结果通过 SSE 流式返回并持久化到 CloudBase 数据库。 +CloudBase VibeCoding Platform 是一个基于腾讯云 CloudBase 的 AI 编程助手平台。用户通过 Web 界面向 Agent 下达编程指令,Agent 在 **沙箱 infra**(Stateful 控制面 + 沙箱业务镜像 数据面 + gateway 路由)中执行代码操作,结果通过 SSE 流式返回并持久化到 CloudBase 数据库。 ```mermaid graph TB @@ -251,7 +251,7 @@ Agent 调用子 Agent 时,`parent_tool_use_id` 从 SDK 顶层透传至前端 ` ## Sandbox Module -Sandbox 模块为每个 **envId** 提供 Stateful 执行环境(沙箱 infra 控制面 + TRW 数据面),任务共享同一实例上的 `/home/user` 工作区。 +Sandbox 模块为每个 **envId** 提供 Stateful 执行环境(沙箱 infra 控制面 + 沙箱业务镜像 数据面),任务共享同一实例上的 `/home/user` 工作区。 ### Architecture @@ -259,29 +259,29 @@ Sandbox 模块为每个 **envId** 提供 Stateful 执行环境(沙箱 infra flowchart LR Agent["Agent Runtime"] --> Provider["StatefulSandboxProvider"] Provider --> SbxInfra["启动沙箱实例"] - SbxInfra --> TRW["TRW :9000"] - TRW --> FS["File System /home/user"] - TRW --> Bash["Bash / PTY"] - TRW --> Preview["/preview/5173 / 7681"] - TRW --> Git["Git Archive"] + 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["OVC preview proxy"] + Web["Web UI"] --> OvcProxy["OpenVibeCoding preview proxy"] OvcProxy --> Gateway["tcloudbasegateway.com"] - Gateway --> TRW + Gateway --> 沙箱业务镜像 ``` ### Stateful Sandbox Lifecycle 1. **Ensure Tool** — `ensureStatefulTool(envId)` 为环境创建或复用沙箱 Tool 模板(`sdt-xxx`),镜像来自 `STATEFUL_SANDBOX_IMAGE` → `TCR_IMAGE` → 公开 TCR 代码默认 -2. **Acquire Instance** — `SANDBOX_INSTANCE_MODE`:`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 访问 TRW +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 工具经 TRW `/api/tools/*`;CloudBase MCP 由 server 侧 `stateful-mcp-client` 转发 +5. **Execute** — Agent 工具经 沙箱业务镜像 `/api/tools/*`;CloudBase MCP 由 server 侧 `stateful-mcp-client` 转发 6. **Archive** — 任务结束时 Git 归档工作区 -### TRW Workspace API +### 沙箱业务镜像 Workspace API | API | Description | | --- | --- | @@ -292,20 +292,20 @@ flowchart LR ### Sandbox Capabilities -经 gateway 路由到 TRW(OVC 对浏览器暴露 `/api/tasks/:id/preview/:port/*` 反向代理): +经 gateway 路由到 沙箱业务镜像(OpenVibeCoding 对浏览器暴露 `/api/tasks/:id/preview/:port/*` 反向代理): -| Capability | TRW path | Description | +| Capability | 沙箱业务镜像 path | Description | | --- | --- | --- | | 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` | 工作区推送到远端(OVC 经 workspace/env 注入 `GIT_ARCHIVE_*` / `ENABLE_GIT_ARCHIVE`) | -| Miniprogram deploy | `POST /api/jobs/miniprogram-deploy` | 启动部署 job(TRW `ENABLE_VIBECODING`) | +| 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` | 实例健康检查 | -> **已移除**:旧 SCF 时代的子工作区 Scope API(`X-Scope-Id`、`/api/scope/info`、5173–5199 多端口)。`user_resources.scope` 仍指 **CloudBase 环境隔离**(shared / isolated / task),与 TRW Scope 无关。 +> **已移除**:旧 SCF 时代的子工作区 Scope API(`X-Scope-Id`、`/api/scope/info`、5173–5199 多端口)。`user_resources.scope` 仍指 **CloudBase 环境隔离**(shared / isolated / task),与 沙箱业务镜像 Scope 无关。 ### MCP Tool Proxy @@ -374,7 +374,7 @@ interface Artifact { | 微信小程序 | MCP 工具 `publishMiniprogram` | `'image'`(预览二维码)/ `'json'`(上传结果) | | 图片生成 | Default 模式 ImageGen tool | `'link'`(CDN URL) | -小程序部署超过 60s 时 TRW 返回 HTTP 202 + `jobId`;Agent 用 `getDeployJobStatus` 轮询 `GET /api/jobs/:jobId`(OVC 经 `trw-deploy-adapter` 转成旧 envelope)。 +小程序部署超过 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 沙箱 infra + TRW + participant Sandbox as 沙箱 infra + 沙箱业务镜像 participant DB as CloudBase DB participant Git as Git Archive @@ -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 | 沙箱 infra(Stateful + TRW), TCR 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) | diff --git a/docs/cloudrun-deploy.md b/docs/cloudrun-deploy.md index ac21696..88ca838 100644 --- a/docs/cloudrun-deploy.md +++ b/docs/cloudrun-deploy.md @@ -1,6 +1,6 @@ # CloudRun 部署(云托管) -OVC 以根目录 `Dockerfile` 构建**前后端一体**容器,监听 **80**。环境变量在控制台配置,**不要**把 `packages/server/.env` 打进镜像(`.dockerignore` 已排除)。 +OpenVibeCoding 以根目录 `Dockerfile` 构建**前后端一体**容器,监听 **80**。环境变量在控制台配置,**不要**把 `packages/server/.env` 打进镜像(`.dockerignore` 已排除)。 ## 前置 @@ -26,7 +26,7 @@ pnpm deploy:cloud | `NODE_ENV` | `production` | | `ASK_USER_BASE_URL` | 云托管公网根 URL(如 `https://xxx.run.tcloudbase.com`),**不能**用 `127.0.0.1` | -其余与本地 `packages/server/.env` 同名:`TCB_*`、`TCB_API_KEY`、`CODEBUDDY_*`、可选 `GIT_ARCHIVE_*` 等。勿配置 `STATEFUL_TOOL_ID`(多副本应走 DB + `ovc-{TCB_ENV_ID}` Tool 名)。 +其余与本地 `packages/server/.env` 同名:`TCB_*`、`TCB_API_KEY`、`CODEBUDDY_*`、可选 `GIT_ARCHIVE_*` 等。勿配置 `STATEFUL_TOOL_ID`(多副本应走 DB + `openvibecoding-{TCB_ENV_ID}` Tool 名)。 ## 部署后验证 diff --git a/docs/scf-session-sharing.md b/docs/scf-session-sharing.md index 7da4688..56c97ba 100644 --- a/docs/scf-session-sharing.md +++ b/docs/scf-session-sharing.md @@ -1,6 +1,6 @@ # SCF 沙箱 Session 共享改造方案 -> **已废弃**:`feature/stateful-infra` 已迁移至 **沙箱 infra(Stateful + TRW)**(`StatefulSandboxProvider`、`ensureStatefulTool`)。下文描述的是旧 SCF 架构,仅供历史对照;新部署见 [setup.md](./setup.md) 与 [architecture.md](./architecture.md) 的 Sandbox 章节。 +> **已废弃**:`feature/stateful-infra` 已迁移至 **沙箱 infra(Stateful + 沙箱业务镜像)**(`StatefulSandboxProvider`、`ensureStatefulTool`)。下文描述的是旧 SCF 架构,仅供历史对照;新部署见 [setup.md](./setup.md) 与 [architecture.md](./architecture.md) 的 Sandbox 章节。 ## 背景 diff --git a/docs/setup.md b/docs/setup.md index c43b123..d702abb 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -12,7 +12,7 @@ ### 必需项 - **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 已安装并已启动(**stateful 分支**:本地只跑 OVC,沙箱连云端沙箱 infra + TRW,无需本机 `tcb-sandbox serve`) +- Docker 已安装并已启动(**stateful 分支**:本地只跑 OpenVibeCoding,沙箱连云端沙箱 infra + 沙箱业务镜像,无需本机 `tcb-sandbox serve`) - 腾讯云账号,且已准备 CloudBase 环境 - 可用的腾讯云 API 密钥(`SecretId` / `SecretKey`) - 至少一种 CodeBuddy 认证方式: @@ -95,7 +95,7 @@ flowchart TD - CloudBase 相关配置(`TCB_ENV_ID`、`TCB_SECRET_ID`、`TCB_SECRET_KEY`) - CodeBuddy 认证配置 - 数据库提供方配置 -- Stateful 沙箱 infra(控制面 + TRW 数据面)/ TCR 镜像配置 +- Stateful 沙箱 infra(控制面 + 沙箱业务镜像 数据面)/ TCR 镜像配置 - 可选的 GitHub OAuth、代理配置 初始化脚本会优先把 CloudBase 和服务端相关配置写入这里。 @@ -127,17 +127,25 @@ flowchart TD | 变量 | 必需 | 说明 | | --- | --- | --- | | `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_INSTANCE_MODE` | 否 | `shared` / `isolated` — 沙箱**实例**是否跨任务复用 | -| `GIT_ARCHIVE_*` / `GIT_PERSONAL_AUTH` | 否 | 归档与 Link;实例就绪后 `PUT /api/workspace/env` 注入,见 [trw-api-alignment.md](./trw-api-alignment.md) | -| `STATEFUL_MINIPROGRAM_FEATURE` | 否 | `true` 时启用 TRW 小程序 job API | +| `WORKSPACE_ISOLATION` | 否 | `shared` / `isolated` — 沙箱**实例**是否跨任务复用(与 main 同名;`SANDBOX_INSTANCE_MODE` 仍可读作兼容别名) | +| `GIT_ARCHIVE_*` / `GIT_PERSONAL_AUTH` | 否 | 归档与 Link;实例就绪后 `PUT /api/workspace/env` 注入(非 Start 时传 boot env) | +| `STATEFUL_PUBLIC_TCR_REPOSITORY` | 否 | 公开 TCR 仓库名,默认 `tcb-sandbox-public-cbe88d` | -**仅调试脚本**:`STATEFUL_TOOL_ID`、`STATEFUL_SANDBOX_ID`、`STATEFUL_GATEWAY_URL`(默认 gateway 可省略)。 +**网关**:固定 `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 开关。 + +**仅调试脚本**:`STATEFUL_TOOL_ID`、`STATEFUL_SANDBOX_ID`。 **镜像**:须为 `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 密码。 -### 沙箱实例模式(`SANDBOX_INSTANCE_MODE`) +### 沙箱实例模式(`WORKSPACE_ISOLATION`) 与 **`TCB_PROVISION_MODE`(用户 CloudBase 环境)** 独立,详见 [README-zh.md](../README-zh.md) 环境变量一节。 @@ -146,7 +154,7 @@ flowchart TD | `shared`(默认) | 同一支撑 `TCB_ENV_ID` 下,多任务复用沙箱 infra 上同一运行实例(`ensureSingleEnvInstance`) | | `isolated` | 每任务独立实例;优先复用任务上的 `sandboxId`,否则新建 | -配置位置:`packages/server/.env` 的 `SANDBOX_INSTANCE_MODE`;Admin「系统设置」里的 `sandbox_instance_mode`(DB)优先级更高。改模式后**新建任务**最可靠;旧任务若 DB 里已写死 `sandboxMode` 可能仍为旧值。 +配置位置:`packages/server/.env` 的 `WORKSPACE_ISOLATION`;Admin「系统设置」里的 `sandbox_instance_mode`(DB)优先级更高。改模式后**新建任务**最可靠;旧任务若 DB 里已写死 `sandboxMode` 可能仍为旧值。 ## 用户环境模式 diff --git a/docs/trw-api-alignment.md b/docs/trw-api-alignment.md deleted file mode 100644 index fd63d0a..0000000 --- a/docs/trw-api-alignment.md +++ /dev/null @@ -1,40 +0,0 @@ -# OVC ↔ TRW API 对齐 - -对照仓库:`tcb-remote-workspace`(`ENABLE_VIBECODING` + `ENABLE_GIT_ARCHIVE` preset)。 -上游路由台账:`tcb-remote-workspace/docs/vibecoding-branch-sync.md`。 - -## TRW 路由(OVC 会调用的) - -| 方法 | 路径 | TRW 开关 | OVC 调用处 | -| --- | --- | --- | --- | -| GET | `/health` | 始终 | `base-runtime` 探活 | -| PUT | `/api/workspace/env` | 始终 | `stateful-provider`、`cloudbase-mcp` 注入凭证 | -| POST | `/api/workspace/init` | 始终 | `stateful-provider` 初始化工作区 | -| POST | `/api/workspace/snapshot` | 始终 | `stateful-provider` 可选快照 | -| POST | `/api/tools/:tool` | 始终 | mcporter / bash / read 等 | -| GET | `/preview/ports` | vibecoding | `tasks.ts`、`wait-vite-ready` | -| GET | `/preview/:port` | vibecoding | 预览代理 | -| POST | `/api/extend/git_push` | `ENABLE_GIT_ARCHIVE` | `git-archive.ts` | -| POST | `/api/jobs/miniprogram-deploy` | `ENABLE_VIBECODING` | `trw-miniprogram-client.ts` | -| GET | `/api/jobs/:jobId` | `ENABLE_VIBECODING` | 同上(轮询) | - -## 已废弃(OVC 不再调用) - -| 旧路径 | 替代 | -| --- | --- | -| `POST /api/session/init` | `POST /api/workspace/init` | -| `PUT /api/session/env` | `PUT /api/workspace/env` | -| `GET /api/scope/info` | 无(单工作区 `/home/user`) | -| `POST /api/miniprogram/deploy` | `POST /api/jobs/miniprogram-deploy` | -| `GET /api/miniprogram/deploy/status` | `GET /api/jobs/:jobId` | -| `POST /api/tools/git_push` | `POST /api/extend/git_push` | -| `POST /api/tools/miniprogram_deploy` | 仅 jobs HTTP | - -## OVC 实现要点 - -- **共享客户端**:`packages/server/src/sandbox/trw-miniprogram-client.ts` -- **响应适配**:`packages/server/src/sandbox/trw-deploy-adapter.ts`(TRW Job → 旧 MCP envelope) -- **CodeBuddy / Stateful MCP**:`stateful-mcp-client.ts` -- **OpenCode CloudBase MCP**:`publishMiniprogram.ts`、`getDeployJobStatus.ts` -- **小程序开关**:`STATEFUL_MINIPROGRAM_FEATURE=true`(TRW 镜像需 `ENABLE_VIBECODING`) -- **Git 归档**:OVC 在实例就绪后 `PUT /api/workspace/env` 注入 `GIT_ARCHIVE_*`(勿在 `StartSandboxInstance` boot Env 传 `ENABLE_GIT_ARCHIVE`,会拖垮 `/health`) diff --git a/docs/upstream-fork.md b/docs/upstream-fork.md index b585f66..966f020 100644 --- a/docs/upstream-fork.md +++ b/docs/upstream-fork.md @@ -8,7 +8,7 @@ | --- | --- | | 最初模板 | [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 + TRW) | +| **本线** | 当前仓库 `feature/stateful-infra` 及后续分支(沙箱 infra / Stateful + 沙箱业务镜像) | ## 硬分叉基线(不变) @@ -27,8 +27,8 @@ ### 本线相对上游的持久差异(示例) -- 沙箱 infra:Stateful Tool / 实例生命周期、gateway 数据面、TRW vibecoding 镜像 -- `SANDBOX_INSTANCE_MODE`(shared / isolated)与进度文案 +- 沙箱 infra:Stateful Tool / 实例生命周期、gateway 数据面、沙箱业务镜像 vibecoding 镜像 +- `WORKSPACE_ISOLATION`(shared / isolated,与 main 同名)与进度文案 - 公开 TCR 默认镜像、`stateful-vibecoding-image` 解析链 - 与 SCF 时代假设脱钩的文档与默认配置 @@ -37,7 +37,7 @@ | 日期 | 方式 | 上游 `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/TRW 路径 | +| 2026-05-25 | `git merge origin/main` | `4592517`(fix readme 等) | (merge commit) | 约 10 文件冲突;保留 AGS/沙箱业务镜像 路径 | **本次并入的上游 commit**(`43c3e60..a878ddb`): @@ -52,8 +52,8 @@ **当前对齐状态**(2026-05-25): - `git merge-base HEAD origin/main` → `4592517`(已与上游 `main` 最新对齐) -- 本线仍在 merge-base 之上保留 stateful 提交(AGS/TRW、文档、`0699323` 等) -- 中文与 stateful 说明:[README-zh.md](../README-zh.md)、[trw-api-alignment.md](./trw-api-alignment.md) +- 本线仍在 merge-base 之上保留 stateful 提交(AGS/沙箱业务镜像、文档、`0699323` 等) +- 中文与 stateful 说明:[README-zh.md](../README-zh.md)、[setup.md](./setup.md) 下次看上游新提交: diff --git a/packages/server/.env.example b/packages/server/.env.example index 4501d47..d54895a 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -9,10 +9,12 @@ TCB_ENV_ID= TCB_SECRET_ID= TCB_SECRET_KEY= TCB_API_KEY= +# ENABLE_AUTH_MODE=false +# TCB_ACCESS_TOKEN= # required when ENABLE_AUTH_MODE=true (sit_*) CODEBUDDY_API_KEY= -# --- Sandbox (AGS + TRW); first task may CreateSandboxTool once per env --- -# SANDBOX_INSTANCE_MODE=shared +# --- Sandbox (AGS + 沙箱业务镜像); first task may CreateSandboxTool once per env --- +# WORKSPACE_ISOLATION=shared # STATEFUL_SANDBOX_IMAGE= # optional; default public TCR in code if unset # TCR_IMAGE= # from pnpm setup:tcr; used if STATEFUL_SANDBOX_IMAGE unset @@ -20,7 +22,7 @@ CODEBUDDY_API_KEY= # TCB_PROVISION_MODE=shared # GIT_ARCHIVE_REPO= / GIT_ARCHIVE_TOKEN= / GIT_ARCHIVE_USER= # GIT_PERSONAL_AUTH= # Link Git in UI; separate test repo from archive -# STATEFUL_MINIPROGRAM_FEATURE=true +# Miniprogram deploy via 沙箱业务镜像 is enabled by default (no env flag). # ASK_USER_BASE_URL=http://127.0.0.1:3001 # --- Debug only (never in production) --- diff --git a/packages/server/scripts/debug-start-instance.ts b/packages/server/scripts/debug-start-instance.ts index 2ec6ac3..65e1ffc 100644 --- a/packages/server/scripts/debug-start-instance.ts +++ b/packages/server/scripts/debug-start-instance.ts @@ -5,6 +5,7 @@ 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 { describeStatefulToolCustomConfiguration } from '../src/sandbox/ensure-stateful-tool.js' import { mergeInstanceEnvIntoToolConfiguration, @@ -58,12 +59,13 @@ async function main() { } else if (toolCfg && withMergedCfg) { customConfiguration = pickStartCustomConfigurationFromTool(toolCfg) } - const resp = await callAgs('StartSandboxInstance', { + const startParam: Record<string, unknown> = { ToolId: toolId, Timeout: '30m', - AuthMode: 'NONE', ...(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 } diff --git a/packages/server/scripts/probe-gateway-port.ts b/packages/server/scripts/probe-gateway-port.ts index de2568e..169265e 100644 --- a/packages/server/scripts/probe-gateway-port.ts +++ b/packages/server/scripts/probe-gateway-port.ts @@ -12,7 +12,7 @@ import { buildGatewayTarget, buildTrwPreviewGatewayTarget, gatewayFetch, - TRW_SERVICE_PORT, + SANDBOX_BUSINESS_IMAGE_PORT, } from '../src/sandbox/stateful/gateway.js' const here = dirname(fileURLToPath(import.meta.url)) @@ -22,6 +22,7 @@ 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') @@ -37,8 +38,9 @@ async function main() { tcbApiKey, vitePort: port, subpath: path === '/' ? '/' : path, + accessToken, }) - : buildGatewayTarget({ envId, sandboxId, tcbApiKey, port, path }) + : buildGatewayTarget({ envId, sandboxId, tcbApiKey, port, path, accessToken }) console.log('target.url', target.url) console.log('E2b-Sandbox-Port', target.port) @@ -48,7 +50,7 @@ async function main() { 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 !== TRW_SERVICE_PORT && res.status === 500) { + if (port !== SANDBOX_BUSINESS_IMAGE_PORT && res.status === 500) { console.log('\nHint: port not declared on AGS tool — run describe-stateful-tool.ts') } } diff --git a/packages/server/scripts/test-opencode-coding-preview-e2e.mts b/packages/server/scripts/test-opencode-coding-preview-e2e.mts new file mode 100644 index 0000000..635b4d4 --- /dev/null +++ b/packages/server/scripts/test-opencode-coding-preview-e2e.mts @@ -0,0 +1,317 @@ +#!/usr/bin/env tsx +/** + * 【过时】上游 main 保留的 SCF 环境 e2e,feature/stateful-infra 分支请勿运行。 + * 依赖 `scf-sandbox-manager`,仅适用于 SCF 沙箱;AGS + 沙箱业务镜像请用 verify-stateful-e2e.ts。 + * Stateful: `npx tsx --env-file=.env scripts/verify-stateful-e2e.ts` + * + * E2E: OpenCode runtime + coding mode + preview + 第二轮修改 + * + * 验证目标: + * Round 1: hi 激活 → 沙箱初始化 + coding 模板 + Vite dev server 启动 + * → previewUrl 标记 ready,预览路径可访问 + * Round 2: 让 agent 修改 src/App.tsx 中某个文案 → 用 read+write 工具 + * → 重新拉预览,验证内容变更体现在 dev server 输出中 + * + * 用法: + * cd packages/server + * npx tsx --env-file=.env scripts/test-opencode-coding-preview-e2e.mts + */ + +import 'dotenv/config' +import { opencodeAcpRuntime } from '../src/agent/runtime/opencode-acp-runtime.js' +import { scfSandboxManager } from '../src/sandbox/scf-sandbox-manager.js' +import { getDb } from '../src/db/index.js' +import type { AgentCallbackMessage } from '@coder/shared' + +const envId = process.env.TCB_ENV_ID +if (!envId) { + console.error('TCB_ENV_ID not set') + process.exit(1) +} + +const conversationId = `opencode-coding-e2e-${Date.now()}` +const userId = 'e2e-coding-user' + +// 用一个有标记的 marker 字符串,便于在 round 2 中验证替换成功 +// 用普通可读词避免被模型内容审核误判 +const ROUND1_PROMPT = 'hi' +const TITLE_MARKER = `我的精彩应用` +const ROUND2_PROMPT = `请帮我把项目 index.html 的网页标题(title 标签)改成"${TITLE_MARKER}"。简单地说:用 read 工具读 index.html,然后用 edit 或 write 工具把 <title>... 之间的文字替换成"${TITLE_MARKER}",做完告诉我即可。` + +console.log('=== OpenCode coding mode preview e2e ===') +console.log(`envId = ${envId}`) +console.log(`conversationId = ${conversationId}`) +console.log(`titleMarker = ${TITLE_MARKER}\n`) + +// ─── Helpers ──────────────────────────────────────────────────────────── + +interface Recorded { + type: string + name?: string + preview?: string +} + +function makeRecorder(label: string): { + cb: (msg: AgentCallbackMessage) => Promise + events: Recorded[] +} { + const events: Recorded[] = [] + const cb = async (msg: AgentCallbackMessage): Promise => { + events.push({ + type: msg.type, + name: msg.name, + preview: typeof msg.content === 'string' ? msg.content.slice(0, 200) : undefined, + }) + if (msg.type === 'text' && msg.content) process.stdout.write(msg.content) + else if (msg.type === 'tool_use') + console.log(`\n[${label} tool_use ▶] ${msg.name} input=${JSON.stringify(msg.input).slice(0, 200)}`) + else if (msg.type === 'tool_result') + console.log( + `[${label} tool_result ◯] tool_use_id=${msg.tool_use_id} is_error=${msg.is_error} out=${(msg.content || '').slice(0, 200)}`, + ) + else if (msg.type === 'agent_phase') console.log(`\n[${label} phase] ${msg.phase}`) + else if (msg.type === 'error') console.log(`\n[${label} error] ${msg.content}`) + else if (msg.type === 'result') console.log(`\n[${label} result] ${(msg.content || '').slice(0, 200)}`) + } + return { cb, events } +} + +async function waitForResultOrError(events: Recorded[], maxMs = 240_000): Promise { + const start = Date.now() + while (Date.now() - start < maxMs) { + if (events.some((e) => e.type === 'result' || e.type === 'error')) return + await new Promise((r) => setTimeout(r, 500)) + } + throw new Error(`timed out after ${maxMs}ms waiting for result/error`) +} + +// ─── Pre-flight: ensure DB ready, mark task as coding mode ────────────── + +async function ensureTaskCodingMode(): Promise { + const now = Date.now() + try { + await getDb().tasks.create({ + id: conversationId, + userId, + prompt: ROUND1_PROMPT, + title: null, + repoUrl: null, + selectedAgent: 'opencode' as any, + selectedModel: 'mimo/mimo-v2.5-pro', + selectedRuntime: 'opencode-acp', + mode: 'coding', + installDependencies: false, + maxDuration: 30, + keepAlive: false, + enableBrowser: false, + status: 'pending', + progress: 0, + logs: '[]', + error: null, + branchName: null, + sandboxId: null, + sandboxSessionId: envId, + sandboxCwd: `/tmp/workspace/${envId}/${conversationId}`, + sandboxMode: 'shared', + agentSessionId: null, + sandboxUrl: null, + previewUrl: null, + prUrl: null, + prNumber: null, + prStatus: null, + prMergeCommitSha: null, + mcpServerIds: null, + createdAt: now, + updatedAt: now, + } as any) + console.log(`[setup] task created (coding mode)`) + } catch (e) { + console.log(`[setup] task.create failed (may already exist): ${(e as Error).message}`) + } +} + +await ensureTaskCodingMode() + +// ─── Round 1: hi → expect sandbox + coding init + dev server up ───────── + +console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') +console.log('Round 1: 激活 (hi) → 沙箱 + coding 项目 + dev server') +console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n') + +const r1 = makeRecorder('R1') +const r1Start = Date.now() +const r1Result = await opencodeAcpRuntime.chatStream(ROUND1_PROMPT, r1.cb, { + conversationId, + envId, + userId, + mode: 'coding', + model: 'mimo/mimo-v2.5-pro', +}) +console.log(`\n[R1] chatStream returned: turnId=${r1Result.turnId}`) + +await waitForResultOrError(r1.events, 240_000) +const r1Elapsed = ((Date.now() - r1Start) / 1000).toFixed(1) +console.log(`\n[R1] completed in ${r1Elapsed}s`) + +// ─── Round 1 sandbox & dev server validation ──────────────────────────── + +console.log('\n[R1] === sandbox + dev server validation ===') + +const sandbox = await scfSandboxManager.getOrCreate(conversationId, envId, { + mode: 'shared', + workspaceIsolation: 'shared', + isCodingMode: true, +}) + +let scopeInfo: any = null +try { + const headers = await sandbox.getAuthHeaders() + const res = await fetch(`${sandbox.baseUrl}/api/scope/info`, { headers }) + scopeInfo = await res.json().catch(() => null) + console.log(`[R1] scope/info: ${JSON.stringify(scopeInfo).slice(0, 400)}`) +} catch (e) { + console.log(`[R1] scope/info error: ${(e as Error).message}`) +} + +const workspace: string | undefined = scopeInfo?.workspace +const vitePort: number | undefined = scopeInfo?.vitePort +console.log(`[R1] workspace=${workspace} vitePort=${vitePort}`) + +// Check that index.html exists in workspace +let indexHtmlExists = false +let indexHtmlContent = '' +try { + const headers = await sandbox.getAuthHeaders() + const res = await fetch(`${sandbox.baseUrl}/api/tools/read`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify({ path: 'index.html' }), + }) + const data = (await res.json().catch(() => ({}))) as any + if (data.success && typeof data.result?.content === 'string') { + indexHtmlContent = data.result.content + indexHtmlExists = true + console.log(`[R1] index.html exists, size=${indexHtmlContent.length}`) + const titleMatch = indexHtmlContent.match(/]*>([^<]*)<\/title>/i) + console.log(`[R1] current : ${titleMatch?.[1] ?? '(no title)'}`) + } else { + console.log(`[R1] index.html read failed: ${JSON.stringify(data).slice(0, 200)}`) + } +} catch (e) { + console.log(`[R1] read index.html error: ${(e as Error).message}`) +} + +// Check dev server preview path - 用 scope/info 返回的真实 previewUrl +const previewPath: string = scopeInfo?.previewUrl || (vitePort ? `/preview/${vitePort}/` : '/preview/') +let previewOk = false +let previewSnippet = '' +try { + const headers = await sandbox.getAuthHeaders() + const res = await fetch(`${sandbox.baseUrl}${previewPath}`, { headers, signal: AbortSignal.timeout(15000) }) + previewSnippet = (await res.text()).slice(0, 600) + previewOk = res.ok && previewSnippet.length > 0 + console.log( + `[R1] ${previewPath} status=${res.status} ok=${previewOk} snippet="${previewSnippet.slice(0, 200).replace(/\n/g, ' ')}"`, + ) +} catch (e) { + console.log(`[R1] ${previewPath} error: ${(e as Error).message}`) +} + +// Check task previewUrl in DB +const task1 = await getDb().tasks.findById(conversationId) +console.log(`[R1] task.previewUrl = ${task1?.previewUrl}`) + +// ─── Round 2: ask agent to modify title ──────────────────────────────── + +console.log('\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━') +console.log('Round 2: 修改 index.html title → 验证预览更新') +console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n') + +const r2 = makeRecorder('R2') +const r2Start = Date.now() +const r2Result = await opencodeAcpRuntime.chatStream(ROUND2_PROMPT, r2.cb, { + conversationId, + envId, + userId, + mode: 'coding', + model: 'mimo/mimo-v2.5-pro', +}) +console.log(`\n[R2] chatStream returned: turnId=${r2Result.turnId}`) + +await waitForResultOrError(r2.events, 300_000) +const r2Elapsed = ((Date.now() - r2Start) / 1000).toFixed(1) +console.log(`\n[R2] completed in ${r2Elapsed}s`) + +// ─── Round 2 validation: title changed in file & preview ─────────────── + +console.log('\n[R2] === modification validation ===') + +let r2IndexContent = '' +try { + const headers = await sandbox.getAuthHeaders() + const res = await fetch(`${sandbox.baseUrl}/api/tools/read`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify({ path: 'index.html' }), + }) + const data = (await res.json().catch(() => ({}))) as any + if (data.success && typeof data.result?.content === 'string') { + r2IndexContent = data.result.content + const titleMatch = r2IndexContent.match(/<title[^>]*>([^<]*)<\/title>/i) + console.log(`[R2] new <title>: ${titleMatch?.[1] ?? '(no title)'}`) + } +} catch (e) { + console.log(`[R2] read index.html error: ${(e as Error).message}`) +} + +const fileHasMarker = r2IndexContent.includes(TITLE_MARKER) + +// Wait a moment for vite HMR to pick up change, then re-fetch preview +await new Promise((r) => setTimeout(r, 3000)) + +let r2PreviewSnippet = '' +let previewHasMarker = false +try { + const headers = await sandbox.getAuthHeaders() + const res = await fetch(`${sandbox.baseUrl}${previewPath}`, { headers, signal: AbortSignal.timeout(15000) }) + r2PreviewSnippet = await res.text() + previewHasMarker = r2PreviewSnippet.includes(TITLE_MARKER) + const titleMatch = r2PreviewSnippet.match(/<title[^>]*>([^<]*)<\/title>/i) + console.log(`[R2] preview <title>: ${titleMatch?.[1] ?? '(no title)'} (status=${res.status})`) +} catch (e) { + console.log(`[R2] ${previewPath} error: ${(e as Error).message}`) +} + +// ─── Summary & assertions ────────────────────────────────────────────── + +const r1Counts: Record<string, number> = {} +for (const e of r1.events) r1Counts[e.type] = (r1Counts[e.type] ?? 0) + 1 +const r2Counts: Record<string, number> = {} +for (const e of r2.events) r2Counts[e.type] = (r2Counts[e.type] ?? 0) + 1 + +console.log('\n\n=========================================================') +console.log('FINAL SUMMARY') +console.log('=========================================================') +console.log(`R1 events: ${JSON.stringify(r1Counts)}`) +console.log(`R2 events: ${JSON.stringify(r2Counts)}`) +console.log() +console.log('R1 (sandbox + coding init):') +console.log(` workspace path: ${workspace ? 'PASS' : 'FAIL'} (${workspace})`) +console.log(` index.html exists: ${indexHtmlExists ? 'PASS' : 'FAIL'}`) +console.log(` /preview/ accessible: ${previewOk ? 'PASS' : 'FAIL'}`) +console.log(` task.previewUrl set: ${task1?.previewUrl ? 'PASS' : 'FAIL'} (${task1?.previewUrl})`) +console.log(` R1 has result event: ${r1Counts.result ? 'PASS' : 'FAIL'}`) +console.log() +console.log('R2 (file modification + preview):') +console.log(` R2 has result event: ${r2Counts.result ? 'PASS' : 'FAIL'}`) +console.log(` R2 used tools: ${r2Counts.tool_use ? `PASS (${r2Counts.tool_use})` : 'FAIL'}`) +console.log(` index.html contains marker: ${fileHasMarker ? 'PASS' : 'FAIL'}`) +console.log(` /preview/ contains marker: ${previewHasMarker ? 'PASS' : 'FAIL'}`) + +const r1Ok = !!workspace && indexHtmlExists && previewOk && (r1Counts.result ?? 0) > 0 +const r2Ok = (r2Counts.result ?? 0) > 0 && (r2Counts.tool_use ?? 0) > 0 && fileHasMarker +const overall = r1Ok && r2Ok + +console.log('\nOVERALL:', overall ? 'PASS ✓' : 'FAIL ✗') + +process.exit(overall ? 0 : 1) diff --git a/packages/server/scripts/test-opencode-sandbox-e2e.mts b/packages/server/scripts/test-opencode-sandbox-e2e.mts new file mode 100644 index 0000000..a3db141 --- /dev/null +++ b/packages/server/scripts/test-opencode-sandbox-e2e.mts @@ -0,0 +1,154 @@ +#!/usr/bin/env tsx +/** + * 【过时】上游 main 保留的 SCF 环境 e2e,feature/stateful-infra 分支请勿运行。 + * 依赖 `scf-sandbox-manager`,仅适用于 SCF 沙箱;AGS + 沙箱业务镜像请用 verify-stateful-e2e.ts。 + * Stateful: `npx tsx --env-file=.env scripts/verify-stateful-e2e.ts` + * + * 沙箱隔离 e2e 测试(新架构 - tool override + env 注入) + * + * 验证链路: + * LLM → 调 write 工具 (被 ~/.config/opencode/tools/write.ts 覆盖) + * → 读 process.env.SANDBOX_BASE_URL + SANDBOX_AUTH_HEADERS_JSON + * → fetch 沙箱 /api/tools/write + * → 沙箱容器里真实写文件 + * + * 断言: + * 1. LLM 使用了 write 工具(名字就是 write,非 sbx_* 前缀) + * 2. 直接调沙箱 /api/tools/read 能读到预期内容(独立验证) + * 3. 本地文件系统未被污染 + * + * 用法: + * npx tsx --env-file=.env scripts/test-opencode-sandbox-e2e.mts + */ + +import 'dotenv/config' +import fs from 'node:fs' +import { opencodeAcpRuntime } from '../src/agent/runtime/opencode-acp-runtime.js' +import { scfSandboxManager } from '../src/sandbox/scf-sandbox-manager.js' +import type { AgentCallbackMessage } from '@coder/shared' + +const envId = process.env.TCB_ENV_ID +if (!envId) { + console.error('TCB_ENV_ID not set in env — cannot run sandbox e2e') + process.exit(1) +} + +const conversationId = 'sandbox-e2e-v2-' + Date.now() +const testFilename = `hello-sandbox-v2-${Date.now()}.txt` +const expectedContent = `hello from v2 ${Math.random().toString(36).slice(2, 10)}` +const sandboxRelPath = testFilename + +console.log(`[sandbox-e2e-v2] envId=${envId}`) +console.log(`[sandbox-e2e-v2] conversationId=${conversationId}`) +console.log(`[sandbox-e2e-v2] testFile (relative)=${sandboxRelPath}`) +console.log(`[sandbox-e2e-v2] expectedContent=${JSON.stringify(expectedContent)}\n`) + +// 清理潜在的本地同名文件(e2e 将验证本地不被污染) +const localMirror = `/tmp/${testFilename}` +try { + fs.unlinkSync(localMirror) +} catch { + /* noop */ +} + +interface RecordedEvent { + type: string + name?: string + content?: string +} +const events: RecordedEvent[] = [] + +const cb = async (msg: AgentCallbackMessage): Promise<void> => { + events.push({ + type: msg.type, + name: msg.name, + content: typeof msg.content === 'string' ? msg.content.slice(0, 180) : undefined, + }) + if (msg.type === 'text' && msg.content) process.stdout.write(msg.content) + else if (msg.type === 'tool_use') + console.log(`\n[tool_use ▶] name=${msg.name} id=${msg.id} input=${JSON.stringify(msg.input).slice(0, 200)}`) + else if (msg.type === 'tool_result') + console.log( + `[tool_result ◯] tool_use_id=${msg.tool_use_id} is_error=${msg.is_error} out=${(msg.content || '').slice(0, 200)}`, + ) + else if (msg.type === 'agent_phase') console.log(`\n[phase] ${msg.phase}`) + else if (msg.type === 'error') console.log(`\n[error] ${msg.content}`) + else if (msg.type === 'result') console.log(`\n[result] ${msg.content}`) +} + +console.log('[sandbox-e2e-v2] === starting chatStream (sandbox mode) ===') +const { turnId } = await opencodeAcpRuntime.chatStream( + `请使用 write 工具创建文件 ${sandboxRelPath}(使用相对路径),内容**完全等于**这一个字符串:${expectedContent}\n不要加引号、不要加 markdown、不要多余换行、不要添加任何其他文字。完成后简短告诉我已完成。`, + cb, + { + conversationId, + envId, + userId: 'e2e-user', + model: 'moonshot/kimi-k2-0905-preview', + }, +) +console.log(`\n[sandbox-e2e-v2] chatStream returned: turnId=${turnId}`) + +const startTime = Date.now() +while (Date.now() - startTime < 180_000) { + if (events.find((e) => e.type === 'result' || e.type === 'error')) break + await new Promise((r) => setTimeout(r, 500)) +} + +console.log('\n\n[sandbox-e2e-v2] === validation ===') + +const counts: Record<string, number> = {} +for (const e of events) counts[e.type] = (counts[e.type] ?? 0) + 1 +console.log('[sandbox-e2e-v2] event counts:', JSON.stringify(counts)) + +// 1. 确认 LLM 用了 write 工具 +const writeToolUses = events.filter((e) => e.type === 'tool_use' && e.name === 'write') +console.log(`[sandbox-e2e-v2] 'write' tool_use count: ${writeToolUses.length}`) + +// 2. 独立验证沙箱里的文件 +console.log('\n[sandbox-e2e-v2] querying sandbox /api/tools/read to verify...') +let sandboxReadOk = false +let sandboxReadContent = '' +try { + const sandbox = await scfSandboxManager.getOrCreate(conversationId, envId, { + mode: 'shared', + workspaceIsolation: 'shared', + }) + const headers = await sandbox.getAuthHeaders() + const res = await fetch(`${sandbox.baseUrl}/api/tools/read`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify({ path: sandboxRelPath }), + }) + const data = (await res.json().catch(() => ({}))) as { success?: boolean; result?: any; error?: string } + if (data.success && typeof data.result?.content === 'string') { + sandboxReadContent = data.result.content + sandboxReadOk = sandboxReadContent.includes(expectedContent) + console.log(`[sandbox-e2e-v2] sandbox file content = ${JSON.stringify(sandboxReadContent.slice(0, 200))}`) + } else { + console.log(`[sandbox-e2e-v2] sandbox read failed: ${data.error ?? JSON.stringify(data).slice(0, 200)}`) + } +} catch (e) { + console.log(`[sandbox-e2e-v2] sandbox read error: ${(e as Error).message}`) +} + +// 3. 本地文件系统干净 +const localExists = fs.existsSync(localMirror) +console.log(`[sandbox-e2e-v2] local ${localMirror} exists = ${localExists} (should be false)`) + +// ─── Assertions ───────────────────────────────────────────────────────────── +const hasText = (counts.text ?? 0) > 0 +const hasResult = (counts.result ?? 0) > 0 +const usedWrite = writeToolUses.length > 0 + +console.log('\n[sandbox-e2e-v2] assertions:') +console.log(` text events: ${hasText ? 'PASS' : 'FAIL'}`) +console.log(` result event: ${hasResult ? 'PASS' : 'FAIL'}`) +console.log(` LLM used 'write' tool: ${usedWrite ? 'PASS' : 'FAIL'} (count=${writeToolUses.length})`) +console.log(` sandbox file has content: ${sandboxReadOk ? 'PASS' : 'FAIL'}`) +console.log(` local NOT polluted: ${!localExists ? 'PASS' : 'FAIL'}`) + +const overall = hasText && hasResult && usedWrite && sandboxReadOk && !localExists +console.log(`\n[sandbox-e2e-v2] OVERALL: ${overall ? 'PASS' : 'FAIL'}`) + +process.exit(overall ? 0 : 1) diff --git a/packages/server/scripts/update-stateful-tool-image.ts b/packages/server/scripts/update-stateful-tool-image.ts index c02f88f..b8bcd0b 100644 --- a/packages/server/scripts/update-stateful-tool-image.ts +++ b/packages/server/scripts/update-stateful-tool-image.ts @@ -1,5 +1,5 @@ /** - * Point an existing stateful SDT at a new container image (after TRW rebuild). + * Point an existing stateful SDT at a new container image (after 沙箱业务镜像 rebuild). * * Usage (from packages/server, with .env loaded): * STATEFUL_TOOL_ID=sdt-xxx STATEFUL_SANDBOX_IMAGE=ccr.../tcb-sandbox-ags:app-vibecoding \ diff --git a/packages/server/scripts/verify-stateful-e2e.ts b/packages/server/scripts/verify-stateful-e2e.ts index e1bd42c..6820852 100644 --- a/packages/server/scripts/verify-stateful-e2e.ts +++ b/packages/server/scripts/verify-stateful-e2e.ts @@ -73,15 +73,12 @@ async function trwBash( async function main() { const t0 = Date.now() - const envId = process.env.TCB_ENV_ID || 'ovc-verify-env' - const conversationId = `ovc-verify-${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.STATEFUL_GATEWAY_URL?.includes('tcloudbasegateway.com') || process.env.TCB_ENV_ID), - ) + emit('config_gateway_url', !!process.env.TCB_ENV_ID) emit('config_tcb_api_key', !!process.env.TCB_API_KEY) let inst: Awaited<ReturnType<typeof provider.acquire>> diff --git a/packages/server/src/agent/cloudbase-agent.service.ts b/packages/server/src/agent/cloudbase-agent.service.ts index a65ee62..d0a6e84 100644 --- a/packages/server/src/agent/cloudbase-agent.service.ts +++ b/packages/server/src/agent/cloudbase-agent.service.ts @@ -567,9 +567,9 @@ export class CloudbaseAgentService { .catch(() => null) const { sandboxCwd: resolvedCwd } = sandboxConfig - // Remote TRW workspace path (semantic only on stateful; tools run in sandbox via MCP). + // Remote 沙箱业务镜像 workspace path (semantic only on stateful; tools run in sandbox via MCP). const workspaceCwd = cwd || resolvedCwd - // CodeBuddy SDK runs on the OVC host — never mkdir/query against /home/user on macOS. + // 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 }) @@ -929,7 +929,7 @@ export class CloudbaseAgentService { } // ── Coding mode: mark preview ready ───────────────────────────────────── - // TRW POST /api/workspace/init handles workspace bootstrap in the stateful provider. + // 沙箱业务镜像 POST /api/workspace/init handles workspace bootstrap in the stateful provider. // - seedCodingTemplate: 从内置模板复制(零延迟) // - ensureViteDev: 自动启动 vite dev server + crash 重启 // - node_modules 恢复: tar.gz 缓存 / npm install @@ -1211,7 +1211,7 @@ export class CloudbaseAgentService { const publishableKey = await getPublishableKey(userContext.envId) // 构建 query 参数 - 和 tcb-headless-service buildQueryOptions 一致 - // cwd=localCwd:仅 SDK 会话 JSONL 落盘;Bash/Read/Write 经 CODEBUDDY_TOOL_OVERRIDE 走远程 TRW。 + // cwd=localCwd:仅 SDK 会话 JSONL 落盘;Bash/Read/Write 经 CODEBUDDY_TOOL_OVERRIDE 走远程 沙箱业务镜像。 const appendPromptOpts = { remoteToolsActive: !!toolOverrideConfig, localHostCwd: localCwd, diff --git a/packages/server/src/agent/coding-mode.ts b/packages/server/src/agent/coding-mode.ts index 6fbba42..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=/ (TRW vite-dev-manager) which overrides this for preview + * - 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=/ (TRW vite-dev-manager) which overrides this for preview +// - 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({ @@ -54,7 +54,7 @@ IMPORTANT: 页面需要做好 error 处理,显示出具体的错误堆栈信 </tech-stack> <workspace-location> -项目代码在 **远程沙箱** 的 \`/home/user\`(或 prepare 返回的 workspace),不在本机 \`/tmp\` / \`ovc-agent\` 目录。 +项目代码在 **远程沙箱** 的 \`/home/user\`(或 prepare 返回的 workspace),不在本机 \`/tmp\` / \`openvibecoding-agent\` 目录。 用户问 pwd、列文件、统计文件数:必须用 **Bash/Read/Glob** 在远程执行后作答,禁止根据 SDK 本机会话目录猜测。 </workspace-location> diff --git a/packages/server/src/agent/runtime/base-runtime.ts b/packages/server/src/agent/runtime/base-runtime.ts index 6bc9a40..f6605ac 100644 --- a/packages/server/src/agent/runtime/base-runtime.ts +++ b/packages/server/src/agent/runtime/base-runtime.ts @@ -82,8 +82,8 @@ export async function waitForSandboxHealth( } /** - * @deprecated Use SandboxProvider.prepare() (TRW POST /api/workspace/init). - * Kept for exports/tests; stateful path returns TRW workspace root immediately. + * @deprecated Use SandboxProvider.prepare() (沙箱业务镜像 POST /api/workspace/init). + * Kept for exports/tests; stateful path returns 沙箱业务镜像 workspace root immediately. */ export async function initSandboxWorkspace( sandbox: SandboxInstance, @@ -156,9 +156,9 @@ export async function getPublishableKey(envId: string): Promise<string> { } export interface BuildAppendPromptOptions { - /** CodeBuddy SDK session dir on the OVC host (not the user workspace). */ + /** CodeBuddy SDK session dir on the OpenVibeCoding 服务端主机 (not the user workspace). */ localHostCwd?: string - /** Bash/Read/Write/Glob/Grep are routed to the remote TRW sandbox. */ + /** Bash/Read/Write/Glob/Grep are routed to the remote 沙箱业务镜像 sandbox. */ remoteToolsActive?: boolean } @@ -172,11 +172,11 @@ function buildRemoteWorkspaceSection( if (remoteActive) { const localLine = localHostCwd - ? `- **本机 SDK 会话目录** \`${localHostCwd}\`:仅用于 CodeBuddy 落盘 JSONL,**不是**用户工作区。禁止把该路径、\`/tmp\`、\`/var/folders\`、\`ovc-agent\` 说成「当前工作目录」。` - : `- **本机 SDK 会话目录**:仅用于 CodeBuddy 落盘,**不是**用户工作区。禁止把 \`/tmp\`、\`/var/folders\`、\`ovc-agent\` 说成「当前工作目录」。` + ? `- **本机 SDK 会话目录** \`${localHostCwd}\`:仅用于 CodeBuddy 落盘 JSONL,**不是**用户工作区。禁止把该路径、\`/tmp\`、\`/var/folders\`、\`openvibecoding-agent\` 说成「当前工作目录」。` + : `- **本机 SDK 会话目录**:仅用于 CodeBuddy 落盘,**不是**用户工作区。禁止把 \`/tmp\`、\`/var/folders\`、\`openvibecoding-agent\` 说成「当前工作目录」。` return ` <remote-workspace priority="highest"> -你已连接 **CloudBase 远程沙箱**(Stateful TRW)。用户项目只存在于沙箱 VM 内。 +你已连接 **CloudBase 远程沙箱**(Stateful 沙箱业务镜像)。用户项目只存在于沙箱 VM 内。 - **远程工作区根目录**:\`${sandboxCwd}\` — 所有 Bash / Read / Write / Glob / Grep 在该 VM 上执行。 ${localLine} diff --git a/packages/server/src/lib/cloudbase-mcp.ts b/packages/server/src/lib/cloudbase-mcp.ts index e1ca4ac..a01ea86 100644 --- a/packages/server/src/lib/cloudbase-mcp.ts +++ b/packages/server/src/lib/cloudbase-mcp.ts @@ -214,7 +214,7 @@ export interface CreateInjectCredentialsOptions { /** * 创建一个 injectCredentials 函数:通过 issueTempCredentials 拿凭证(永久密钥优先), - * 调 TRW PUT /api/workspace/env 写入。 + * 调 沙箱业务镜像 PUT /api/workspace/env 写入。 */ export function createInjectCredentials(opts: CreateInjectCredentialsOptions): InjectCredentialsFn { const { userId, envId, conversationId, sandboxFetch, workspaceFolderPaths, on401 } = opts diff --git a/packages/server/src/lib/sandbox-config.ts b/packages/server/src/lib/sandbox-config.ts index d06ade7..dca8fba 100644 --- a/packages/server/src/lib/sandbox-config.ts +++ b/packages/server/src/lib/sandbox-config.ts @@ -1,5 +1,5 @@ /** - * Sandbox config — TRW workspace root + instance isolation mode (shared | isolated). + * Sandbox config — 沙箱业务镜像 workspace root + instance isolation mode (shared | isolated). */ import os from 'node:os' @@ -7,7 +7,7 @@ import path from 'node:path' import { getDb } from '../db/index.js' import { getProvisionMode, type ProvisionMode } from './provision-config.js' -/** TRW vibecoding preset workspace root (flat project tree). */ +/** 沙箱业务镜像 vibecoding preset workspace root (flat project tree). */ export const STATEFUL_WORKSPACE_ROOT = '/home/user' export type SandboxInstanceMode = 'shared' | 'isolated' @@ -47,12 +47,12 @@ export function normalizeSandboxCwd(cwd: string | null | undefined): string { /** * Host path for CodeBuddy SDK session JSONL (hash(cwd) under ~/.codebuddy/projects). - * TRW workspace is /home/user on the sandbox VM; the SDK must not use that path on macOS. + * 沙箱业务镜像 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(), 'ovc-agent', conversationId) + return path.join(os.tmpdir(), 'openvibecoding-agent', conversationId) } return workspaceCwd } @@ -65,14 +65,16 @@ export function resolveSandboxConfig(params: ResolveParams): SandboxConfig { /** * Default instance mode for new tasks (before per-task override). - * Priority: DB `sandbox_instance_mode` → env `SANDBOX_INSTANCE_MODE` → provision-aware builtin. + * Priority: DB `sandbox_instance_mode` → env `WORKSPACE_ISOLATION` → provision-aware builtin. */ export async function resolveSandboxInstanceMode(): Promise<{ value: SandboxInstanceMode source: SandboxInstanceModeSource envDefault: SandboxInstanceMode }> { - const envDefault = normalizeSandboxMode(process.env.SANDBOX_INSTANCE_MODE || BUILTIN_DEFAULT) + const envIsolation = + process.env.WORKSPACE_ISOLATION || process.env.SANDBOX_INSTANCE_MODE || '' + const envDefault = normalizeSandboxMode(envIsolation || BUILTIN_DEFAULT) try { const setting = await getDb().settings.findSystemSetting('sandbox_instance_mode') @@ -83,7 +85,7 @@ export async function resolveSandboxInstanceMode(): Promise<{ // DB unavailable } - if (process.env.SANDBOX_INSTANCE_MODE) { + if (envIsolation) { return { value: envDefault, source: 'env', envDefault } } diff --git a/packages/server/src/middleware/mcp/cloudbase/README.md b/packages/server/src/middleware/mcp/cloudbase/README.md index 35abf0e..9f20a92 100644 --- a/packages/server/src/middleware/mcp/cloudbase/README.md +++ b/packages/server/src/middleware/mcp/cloudbase/README.md @@ -85,8 +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 | TRW `POST /api/jobs/miniprogram-deploy` | -| `getDeployJobStatus.ts` | `getDeployJobStatus`(新增) | **[CORE]** augment | TRW `GET /api/jobs/:jobId` | +| `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 | @@ -188,7 +188,7 @@ middleware/mcp/cloudbase/ # CloudBase 专用,所有 policy 平铺在 └── cronTask.ts # [CORE] routes/cloudbase-mcp.ts # OpenCode HTTP runtime 入口 -sandbox/stateful/stateful-mcp-client.ts # CodeBuddy SDK runtime(经 TRW 数据面) +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/getDeployJobStatus.ts b/packages/server/src/middleware/mcp/cloudbase/getDeployJobStatus.ts index a0d62c4..73420c3 100644 --- a/packages/server/src/middleware/mcp/cloudbase/getDeployJobStatus.ts +++ b/packages/server/src/middleware/mcp/cloudbase/getDeployJobStatus.ts @@ -1,7 +1,7 @@ /** * Augmented tool: getDeployJobStatus * - * Poll TRW: GET /api/jobs/:jobId (replaces /api/miniprogram/deploy/status) + * Poll 沙箱业务镜像: GET /api/jobs/:jobId (replaces /api/miniprogram/deploy/status) */ import type { McpPolicy } from './_index.js' diff --git a/packages/server/src/middleware/mcp/cloudbase/publishMiniprogram.ts b/packages/server/src/middleware/mcp/cloudbase/publishMiniprogram.ts index 4b8b380..20efbc6 100644 --- a/packages/server/src/middleware/mcp/cloudbase/publishMiniprogram.ts +++ b/packages/server/src/middleware/mcp/cloudbase/publishMiniprogram.ts @@ -1,7 +1,7 @@ /** * Augmented tool: publishMiniprogram * - * Calls TRW vibecoding jobs API: + * Calls 沙箱业务镜像 vibecoding jobs API: * POST /api/jobs/miniprogram-deploy * Poll via getDeployJobStatus → GET /api/jobs/:jobId */ diff --git a/packages/server/src/routes/tasks.ts b/packages/server/src/routes/tasks.ts index 137d104..f1fa909 100644 --- a/packages/server/src/routes/tasks.ts +++ b/packages/server/src/routes/tasks.ts @@ -2396,7 +2396,7 @@ tasksRouter.get('/:taskId/sandbox-health', requireUserEnv, async (c) => { }) // --------------------------------------------------------------------------- -// GET /:taskId/preview-health — dev server via TRW GET /preview/ports +// GET /:taskId/preview-health — dev server via 沙箱业务镜像 GET /preview/ports // --------------------------------------------------------------------------- tasksRouter.get('/:taskId/preview-health', requireUserEnv, async (c) => { @@ -2757,7 +2757,7 @@ tasksRouter.get('/:taskId/preview-url', requireUserEnv, async (c) => { } } - await emit('progress', '正在等待 TRW Vite 开发服务器就绪...') + await emit('progress', '正在等待 沙箱业务镜像 Vite 开发服务器就绪...') const viteReady = await waitForSandboxViteReady(sandbox, { port: STATEFUL_DEFAULT_VITE_PORT, maxWaitMs: 120_000, @@ -2779,7 +2779,7 @@ tasksRouter.get('/:taskId/preview-url', requireUserEnv, async (c) => { previewBase = `${sandbox!.baseUrl}/preview` } - // Stateful: browser cannot attach gateway auth headers — proxy via OVC. + // 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) { @@ -2819,7 +2819,7 @@ tasksRouter.get('/:taskId/preview-errors', requireUserEnv, async (c) => { return c.json({ ok: true, buildErrors: [], runtimeErrors: [] }) } - // 1) TRW GET /preview/ports — no vite → no errors + // 1) 沙箱业务镜像 GET /preview/ports — no vite → no errors let vitePort: number | null = null try { const portsRes = await sandbox.request('/preview/ports', { @@ -2837,7 +2837,7 @@ tasksRouter.get('/:taskId/preview-errors', requireUserEnv, async (c) => { return c.json({ ok: true, buildErrors: [], runtimeErrors: [] }) } - // 2) Same path as preview iframe: TRW /preview/{port}/__dev_errors via gateway auth + // 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, { diff --git a/packages/server/src/sandbox/ensure-stateful-tool.ts b/packages/server/src/sandbox/ensure-stateful-tool.ts index 5a0cbf3..beb7b36 100644 --- a/packages/server/src/sandbox/ensure-stateful-tool.ts +++ b/packages/server/src/sandbox/ensure-stateful-tool.ts @@ -20,7 +20,7 @@ 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 `ovc-${slug || 'default'}` + return `openvibecoding-${slug || 'default'}` } function extractSandboxToolSet(resp: Record<string, unknown>): Array<Record<string, unknown>> { @@ -65,8 +65,6 @@ async function findSandboxToolIdByName(toolName: string): Promise<string | null> } function resolveSandboxGatewayUrl(envId: string): string { - const explicit = process.env.STATEFUL_GATEWAY_URL || process.env.STATEFUL_SANDBOX_URL || '' - if (explicit) return explicit.replace(/\/$/, '') if (!envId) throw new Error('Missing envId to derive stateful sandbox gateway URL') return `https://${envId}.api.tcloudbasegateway.com/v1/sandbox/-` } diff --git a/packages/server/src/sandbox/git-archive.ts b/packages/server/src/sandbox/git-archive.ts index 5232762..33f74a6 100644 --- a/packages/server/src/sandbox/git-archive.ts +++ b/packages/server/src/sandbox/git-archive.ts @@ -8,6 +8,7 @@ import type { SandboxInstance } from './provider/types.js' import { STATEFUL_WORKSPACE_ROOT } from '../lib/sandbox-config.js' +import { buildStatefulWorkspaceAuthEnv } from './stateful-sandbox-auth.js' // ─── Types ──────────────────────────────────────────────────────── @@ -65,7 +66,7 @@ export function isGitArchiveConfigured(): boolean { } /** - * TRW git archive vars for PUT /api/workspace/env or workspace/init `env`. + * 沙箱业务镜像 git archive vars for PUT /api/workspace/env or workspace/init `env`. * Do not pass via StartSandboxInstance CustomConfiguration.Env — boot-time * ENABLE_GIT_ARCHIVE blocks /health and fails AGS port binding. */ @@ -80,7 +81,7 @@ export function buildGitArchiveWorkspaceEnv(): Record<string, string> { 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 + return { ...env, ...buildStatefulWorkspaceAuthEnv() } } /** Debug-only: AGS CustomConfiguration.Env array shape. */ @@ -110,7 +111,7 @@ export async function injectGitArchiveWorkspaceEnv(sandbox: SandboxInstance): Pr /** * 将沙箱中的变更推送到 Git 归档仓库 * - * 通过 TRW POST /api/extend/git_push 端点执行 git 操作 + * 通过 沙箱业务镜像 POST /api/extend/git_push 端点执行 git 操作 * * @param sandbox 沙箱实例 * @param conversationId 会话 ID(用作分支名) diff --git a/packages/server/src/sandbox/preview-proxy.ts b/packages/server/src/sandbox/preview-proxy.ts index 94cdc16..4baa598 100644 --- a/packages/server/src/sandbox/preview-proxy.ts +++ b/packages/server/src/sandbox/preview-proxy.ts @@ -1,5 +1,5 @@ /** - * Proxy browser preview requests to TRW /preview/{port}/ via stateful gateway auth headers. + * Proxy browser preview requests to 沙箱业务镜像 /preview/{port}/ via stateful gateway auth headers. */ import type { Context } from 'hono' @@ -40,7 +40,7 @@ function trwPreviewPrefix(port: string): string { return `/preview/${normalizePort(port)}` } -/** Rewrite TRW vite base paths so subresources load through the OVC proxy route. */ +/** 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) diff --git a/packages/server/src/sandbox/preview-ws-proxy.ts b/packages/server/src/sandbox/preview-ws-proxy.ts index d91c0e0..a0536a8 100644 --- a/packages/server/src/sandbox/preview-ws-proxy.ts +++ b/packages/server/src/sandbox/preview-ws-proxy.ts @@ -1,5 +1,5 @@ /** - * WebSocket upgrade proxy: browser → OVC → TRW /preview/{port}/ (vite HMR). + * WebSocket upgrade proxy: browser → OpenVibeCoding → 沙箱业务镜像 /preview/{port}/ (vite HMR). */ import type { IncomingMessage, Server } from 'node:http' diff --git a/packages/server/src/sandbox/provider/stateful-provider.ts b/packages/server/src/sandbox/provider/stateful-provider.ts index f1a7150..1f6d6d9 100644 --- a/packages/server/src/sandbox/provider/stateful-provider.ts +++ b/packages/server/src/sandbox/provider/stateful-provider.ts @@ -1,7 +1,7 @@ /** - * Stateful sandbox provider (CloudBase AGS control plane + TRW data plane). + * Stateful sandbox provider (CloudBase AGS control plane + 沙箱业务镜像 data plane). * - * TRW workspace protocol: + * 沙箱业务镜像 workspace protocol: * - PUT /api/workspace/env inject credentials * - POST /api/workspace/init initialize workspace * - GET /health health probe @@ -14,7 +14,8 @@ * 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 (long-lived JWT) used as X-Cloudbase-Authorization Bearer. + * 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. */ @@ -41,7 +42,12 @@ import { STATEFUL_WORKSPACE_ROOT } from '../../lib/sandbox-config.js' import { buildGitArchiveWorkspaceEnv, injectGitArchiveWorkspaceEnv } from '../git-archive.js' import { ensureStatefulTool, resolveStatefulGatewayUrl } from '../ensure-stateful-tool.js' import { startStatefulInstanceWithWarmup } from '../stateful-tool-warmup.js' -import { buildDataPlaneHeaders, TRW_SERVICE_PORT } from '../stateful/gateway.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' // ─── Constants ──────────────────────────────────────────────────────────── @@ -55,6 +61,8 @@ const PREPARE_INIT_TIMEOUT_MS = 300_000 interface StatefulRuntimeConfig { tcbApiKey: string + enableAuthMode: boolean + accessToken: string sandboxBaseUrl: string toolId: string preCreatedSandboxId: string @@ -65,7 +73,10 @@ interface StatefulRuntimeConfig { } 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 = @@ -85,6 +96,8 @@ function readStatefulRuntimeConfig(envId: string, toolId: string): StatefulRunti return { tcbApiKey, + enableAuthMode, + accessToken, sandboxBaseUrl, toolId, preCreatedSandboxId, @@ -95,6 +108,19 @@ function readStatefulRuntimeConfig(envId: string, toolId: string): StatefulRunti } } +function buildStatefulDataPlaneHeaders( + cfg: StatefulRuntimeConfig, + sandboxId: string, + port: number = SANDBOX_BUSINESS_IMAGE_PORT, +): Record<string, string> { + return buildDataPlaneHeaders({ + tcbApiKey: cfg.tcbApiKey, + sandboxId, + port, + accessToken: cfg.enableAuthMode ? cfg.accessToken : undefined, + }) +} + // ─── Instance meta bag ──────────────────────────────────────────────────── interface StatefulMetaBag { @@ -102,6 +128,8 @@ interface StatefulMetaBag { conversationId: string toolId: string tcbApiKey: string + enableAuthMode: boolean + accessToken: string sandboxMode: SandboxInstanceMode cacheKey: string } @@ -122,13 +150,23 @@ function buildStatefulInstance(args: { baseUrl: string envId: string conversationId: string - tcbApiKey: string + cfg: StatefulRuntimeConfig sandboxMode: SandboxInstanceMode cacheKey: string }): SandboxInstance { - const { sandboxId, toolId, baseUrl, envId, conversationId, tcbApiKey, sandboxMode, cacheKey } = args - const meta: StatefulMetaBag = { envId, conversationId, toolId, tcbApiKey, sandboxMode, cacheKey } - const authHeaders = buildDataPlaneHeaders({ tcbApiKey, sandboxId, port: TRW_SERVICE_PORT }) + 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, @@ -138,16 +176,16 @@ function buildStatefulInstance(args: { mcpConfig: { type: 'http', url: `${baseUrl}/mcp`, - headers: authHeaders, + headers: initialHeaders, }, async getAuthHeaders() { - return { ...authHeaders } + return authHeaders() }, async request(p, opts) { return fetch(`${baseUrl}${p}`, { ...opts, headers: { - ...authHeaders, + ...authHeaders(), ...((opts?.headers as Record<string, string> | undefined) ?? {}), }, }) @@ -157,7 +195,7 @@ function buildStatefulInstance(args: { // ─── Tool override module path ──────────────────────────────────────────── // Stateful runtime reuses tool-override.cjs; CLI patch consumes protocol-neutral -// payload (TRW /api/tools/* + e2b envd). Only {url, headers} differ per instance. +// payload (沙箱业务镜像 /api/tools/* + e2b envd). Only {url, headers} differ per instance. function getStatefulToolOverridePath(): string { const here = path.dirname(fileURLToPath(import.meta.url)) @@ -200,15 +238,9 @@ async function callAgsManagerApi( } async function startStatefulInstance(cfg: StatefulRuntimeConfig, toolId: string): Promise<string> { - const result = (await callAgsManagerApi( - 'StartSandboxInstance', - { - ToolId: toolId, - Timeout: '30m', - AuthMode: 'NONE', - }, - cfg, - )) as Record<string, unknown> + const startParam: Record<string, unknown> = { ToolId: toolId, Timeout: '30m' } + if (!cfg.enableAuthMode) startParam.AuthMode = 'NONE' + const result = (await callAgsManagerApi('StartSandboxInstance', startParam, cfg)) as Record<string, unknown> const data = result?.data as Record<string, unknown> | undefined const instanceObj = result?.Instance as Record<string, unknown> | undefined const instanceId = String(result?.InstanceId || instanceObj?.InstanceId || data?.InstanceId || '') || '' @@ -358,8 +390,8 @@ async function checkHealth(baseUrl: string, headers: Record<string, string>): Pr } } -async function waitForReady(baseUrl: string, sandboxId: string, tcbApiKey: string): Promise<void> { - const headers = buildDataPlaneHeaders({ tcbApiKey, sandboxId, port: TRW_SERVICE_PORT }) +async function waitForReady(baseUrl: string, sandboxId: string, cfg: StatefulRuntimeConfig): Promise<void> { + const headers = buildStatefulDataPlaneHeaders(cfg, sandboxId) const start = Date.now() while (Date.now() - start < READY_TIMEOUT_MS) { if (await checkHealth(baseUrl, headers)) return @@ -420,15 +452,11 @@ class StatefulProvider implements SandboxProvider { sandboxId = ensured.sandboxId } onProgress?.({ phase: 'wait_ready', message: '确认沙箱实例健康状态...\n' }) - await waitForReady(cfg.sandboxBaseUrl, sandboxId, cfg.tcbApiKey) + await waitForReady(cfg.sandboxBaseUrl, sandboxId, cfg) } // Final health check (covers pre-created path too). - const headers = buildDataPlaneHeaders({ - tcbApiKey: cfg.tcbApiKey, - sandboxId, - port: TRW_SERVICE_PORT, - }) + const headers = buildStatefulDataPlaneHeaders(cfg, sandboxId) if (!(await checkHealth(cfg.sandboxBaseUrl, headers))) { throw new Error(`Sandbox ${sandboxId} not healthy at ${cfg.sandboxBaseUrl}`) } @@ -439,7 +467,7 @@ class StatefulProvider implements SandboxProvider { baseUrl: cfg.sandboxBaseUrl, envId: ctx.envId, conversationId: ctx.conversationId, - tcbApiKey: cfg.tcbApiKey, + cfg, sandboxMode, cacheKey: key, }) @@ -503,7 +531,7 @@ class StatefulProvider implements SandboxProvider { } async release(inst: SandboxInstance, ctx: ReleaseContext): Promise<void> { - // AGS persistence: COS snapshot is auto-managed by TRW (periodic 60s + shutdown). + // 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 @@ -565,7 +593,7 @@ class StatefulProvider implements SandboxProvider { await this.destroy(inst) return } - // shared: one /home/user per env instance — no per-task workspace teardown in TRW. + // shared: one /home/user per env instance — no per-task workspace teardown in 沙箱业务镜像. } // ── Optional admin helpers (not on SandboxProvider interface, but useful) ── 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<string, string> { + return { ENABLE_AUTH_MODE: isStatefulAuthModeEnabled() ? 'true' : 'false' } +} diff --git a/packages/server/src/sandbox/stateful-vibecoding-image.ts b/packages/server/src/sandbox/stateful-vibecoding-image.ts index 835136d..5bfea90 100644 --- a/packages/server/src/sandbox/stateful-vibecoding-image.ts +++ b/packages/server/src/sandbox/stateful-vibecoding-image.ts @@ -8,12 +8,13 @@ import { existsSync, readFileSync } from 'node:fs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' -/** Public CCR namespace for OVC / vibecoding (see code_sandbox/一条龙.md §账号与 CCR). */ +/** 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.OVC_PUBLIC_TCR_REPO?.trim() || 'tcb-sandbox-public-cbe88d' +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 = diff --git a/packages/server/src/sandbox/stateful/gateway.ts b/packages/server/src/sandbox/stateful/gateway.ts index 531677a..89dc3a1 100644 --- a/packages/server/src/sandbox/stateful/gateway.ts +++ b/packages/server/src/sandbox/stateful/gateway.ts @@ -1,13 +1,13 @@ /** * TCB data-plane gateway helpers for stateful AGS instances. * - * Browser iframes cannot send X-Cloudbase-Authorization / E2b-* headers; use OVC + * 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 TRW_SERVICE_PORT = 9000 +export const SANDBOX_BUSINESS_IMAGE_PORT = 9000 export const ENVD_PORT = 49983 export interface StatefulGatewayTarget { @@ -16,7 +16,7 @@ export interface StatefulGatewayTarget { 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 TRW preview use `/preview/{port}/...`. */ + /** Path on the target service (leading slash). For 沙箱业务镜像 preview use `/preview/{port}/...`. */ path: string headers: Record<string, string> url: string @@ -26,18 +26,23 @@ export function buildDataPlaneHeaders(opts: { tcbApiKey: string sandboxId: string port: number + /** Instance sit_* when ENABLE_AUTH_MODE=true (TCB_ACCESS_TOKEN). */ + accessToken?: string }): Record<string, string> { - return { + const headers: Record<string, string> = { '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: TRW HTTP (paths like /health, /preview/5173/, /api/tools/bash) + * - 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: { @@ -47,6 +52,7 @@ export function buildGatewayTarget(args: { port: number path?: string gatewayBaseUrl?: string + accessToken?: string }): StatefulGatewayTarget { const baseUrl = (args.gatewayBaseUrl || resolveStatefulGatewayUrl(args.envId)).replace(/\/$/, '') const path = normalizePath(args.path ?? '/') @@ -54,6 +60,7 @@ export function buildGatewayTarget(args: { tcbApiKey: args.tcbApiKey, sandboxId: args.sandboxId, port: args.port, + accessToken: args.accessToken, }) return { baseUrl, @@ -65,7 +72,7 @@ export function buildGatewayTarget(args: { } } -/** TRW reverse-proxy path for a dev server port (always via TRW :9000). */ +/** 沙箱业务镜像 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}` @@ -78,10 +85,11 @@ export function buildTrwPreviewGatewayTarget(args: { vitePort: number subpath?: string gatewayBaseUrl?: string + accessToken?: string }): StatefulGatewayTarget { return buildGatewayTarget({ ...args, - port: TRW_SERVICE_PORT, + port: SANDBOX_BUSINESS_IMAGE_PORT, path: buildTrwPreviewPath(args.vitePort, args.subpath), }) } diff --git a/packages/server/src/sandbox/stateful/stateful-mcp-client.ts b/packages/server/src/sandbox/stateful/stateful-mcp-client.ts index c0a5320..5e2960e 100644 --- a/packages/server/src/sandbox/stateful/stateful-mcp-client.ts +++ b/packages/server/src/sandbox/stateful/stateful-mcp-client.ts @@ -1,14 +1,14 @@ /** * Stateful sandbox MCP client * - * TRW for_vibecoding / vibecoding preset data plane. + * 沙箱业务镜像 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 (STATEFUL_MINIPROGRAM_FEATURE=true). + * Miniprogram: POST /api/jobs/miniprogram-deploy (enabled by default). */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' @@ -146,8 +146,6 @@ function serializeFnCall(toolName: string, args: Record<string, unknown>): strin // ─── MCP Client factory ────────────────────────────────────────── -const MINIPROGRAM_FEATURE_ENABLED = (process.env.STATEFUL_MINIPROGRAM_FEATURE || '').toLowerCase() === 'true' - async function resolveMiniprogramPrivateKey( appId: string, getMpDeployCredentials: McpDeps['getMpDeployCredentials'], @@ -398,23 +396,7 @@ export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientB })) } - // ── publishMiniprogram (gated by env: AGS master may not have the endpoint) ── - const miniprogramDegradedResponse = (extra?: Record<string, unknown>) => ({ - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - error: true, - message: - 'Miniprogram deploy is not enabled on this AGS deployment. ' + - 'Set STATEFUL_MINIPROGRAM_FEATURE=true once /api/jobs/miniprogram-deploy is available.', - ...extra, - }), - }, - ], - isError: true, - }) - + // ── publishMiniprogram (沙箱业务镜像 /api/jobs/miniprogram-deploy) ── server.tool( 'publishMiniprogram', '小程序发布/预览工具。支持预览(preview)和上传(upload)两种操作。', @@ -427,7 +409,6 @@ export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientB robot: z.number().optional().describe('CI 机器人编号'), }, async (args: Record<string, unknown>) => { - if (!MINIPROGRAM_FEATURE_ENABLED) return miniprogramDegradedResponse() try { return await runStatefulPublishMiniprogram(args, (p, init) => sandbox.request(p, init), getMpDeployCredentials) } catch (e: unknown) { @@ -442,7 +423,6 @@ export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientB '查询小程序发布/预览任务的状态。', { jobId: z.string().describe('publishMiniprogram 返回的 jobId') }, async (args: Record<string, unknown>) => { - if (!MINIPROGRAM_FEATURE_ENABLED) return miniprogramDegradedResponse({ jobId: args.jobId }) try { return await runStatefulDeployJobStatus(args.jobId as string, (p, init) => sandbox.request(p, init)) } catch (e: unknown) { @@ -496,7 +476,7 @@ export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientB ) }) - // SDK-wrapped publishMiniprogram (mirrors server.tool above, gated) + // SDK-wrapped publishMiniprogram (mirrors server.tool above) sdkTools.push( sdkTool( 'publishMiniprogram', @@ -510,7 +490,6 @@ export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientB robot: z.number().optional().describe('CI 机器人编号'), }, async (args: Record<string, unknown>) => { - if (!MINIPROGRAM_FEATURE_ENABLED) return miniprogramDegradedResponse() try { return await runStatefulPublishMiniprogram( args, @@ -531,7 +510,6 @@ export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientB '查询小程序发布/预览任务的状态。', { jobId: z.string().describe('publishMiniprogram 返回的 jobId') }, async (args: Record<string, unknown>) => { - if (!MINIPROGRAM_FEATURE_ENABLED) return miniprogramDegradedResponse({ jobId: args.jobId }) try { return await runStatefulDeployJobStatus(args.jobId as string, (p, init) => sandbox.request(p, init)) } catch (e: unknown) { @@ -542,7 +520,7 @@ export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientB ), ) - // ── cronTask (CRUD via OVC local DB; identical to SCF version) ── + // ── cronTask (CRUD via OpenVibeCoding 本地 DB; identical to SCF version) ── if (depsUserId) { sdkTools.push( sdkTool( @@ -735,7 +713,7 @@ export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientB }) log( - `[stateful-mcp] Ready. baseUrl=${sandbox.baseUrl} sandboxId=${sandbox.id} tools=${cloudbaseTools.length} miniprogram=${MINIPROGRAM_FEATURE_ENABLED}\n`, + `[stateful-mcp] Ready. baseUrl=${sandbox.baseUrl} sandboxId=${sandbox.id} tools=${cloudbaseTools.length} miniprogram=enabled\n`, ) return { diff --git a/packages/server/src/sandbox/task-sandbox.ts b/packages/server/src/sandbox/task-sandbox.ts index f9e4e18..9b12393 100644 --- a/packages/server/src/sandbox/task-sandbox.ts +++ b/packages/server/src/sandbox/task-sandbox.ts @@ -129,7 +129,7 @@ export async function readFileFromSandbox( } } -/** Download file bytes via TRW POST /api/tools/files_download (production TRW has no /e2b-compatible). */ +/** Download file bytes via 沙箱业务镜像 POST /api/tools/files_download (production 沙箱业务镜像 has no /e2b-compatible). */ export async function downloadFileFromSandbox(sandbox: SandboxInstance, filePath: string): Promise<Uint8Array | null> { try { const res = await sandbox.request('/api/tools/files_download', { @@ -168,7 +168,7 @@ export function getProvider(): SandboxProvider { } /** - * @deprecated Prefer waitForSandboxViteReady — TRW vite-dev-manager owns port 5173. + * @deprecated Prefer waitForSandboxViteReady — 沙箱业务镜像 vite-dev-manager owns port 5173. */ export async function ensureDevServerStarted( sandbox: SandboxInstance, diff --git a/packages/server/src/sandbox/tool-override.ts b/packages/server/src/sandbox/tool-override.ts index 23f17fd..e8c43e0 100644 --- a/packages/server/src/sandbox/tool-override.ts +++ b/packages/server/src/sandbox/tool-override.ts @@ -416,7 +416,7 @@ async function drainPtyOutput(baseUrl: string, headers: Record<string, string>, } /** - * 确保指定 pid 在 ptyTaskRegistry 中存在(仅内存;TRW 无 e2b Process/List)。 + * 确保指定 pid 在 ptyTaskRegistry 中存在(仅内存;沙箱业务镜像 无 e2b Process/List)。 */ async function ensurePtyTask(taskId: string): Promise<PtyTask | null> { return ptyTaskRegistry.get(taskId) ?? null @@ -822,7 +822,7 @@ export function overrideTools(toolMap: Map<string, any>): void { return cdnUrl } - // ── fallback:上传到沙箱 via TRW /api/tools/files_upload ── + // ── fallback:上传到沙箱 via 沙箱业务镜像 /api/tools/files_upload ── const sandboxRelPath = `generated-images/${filename}` const contentBase64 = Buffer.from(await blob.arrayBuffer()).toString('base64') const uploadRes = await fetch(`${baseUrl}/api/tools/files_upload`, { diff --git a/packages/server/src/sandbox/trw-deploy-adapter.ts b/packages/server/src/sandbox/trw-deploy-adapter.ts index 226ec86..c4b8fb0 100644 --- a/packages/server/src/sandbox/trw-deploy-adapter.ts +++ b/packages/server/src/sandbox/trw-deploy-adapter.ts @@ -1,7 +1,7 @@ /** - * TRW miniprogram deploy job adapter. + * 沙箱业务镜像 miniprogram deploy job adapter. * - * As of the TRW route refactor (post commit f930f87), the miniprogram deploy + * As of the 沙箱业务镜像 route refactor (post commit f930f87), the miniprogram deploy * surface lives at: * * POST /api/jobs/miniprogram-deploy → start a job @@ -14,7 +14,7 @@ * legacy envelope so the MCP tool layer can stay simple and stable. */ -/** TRW Job record returned by /api/jobs/* endpoints. */ +/** 沙箱业务镜像 Job record returned by /api/jobs/* endpoints. */ export interface TrwJob { jobId: string kind: 'miniprogram-deploy' | string diff --git a/packages/server/src/sandbox/trw-miniprogram-client.ts b/packages/server/src/sandbox/trw-miniprogram-client.ts index 266e52c..a8ae77f 100644 --- a/packages/server/src/sandbox/trw-miniprogram-client.ts +++ b/packages/server/src/sandbox/trw-miniprogram-client.ts @@ -1,7 +1,7 @@ /** - * TRW vibecoding miniprogram deploy HTTP client (shared by Stateful MCP + OpenCode middleware). + * 沙箱业务镜像 vibecoding miniprogram deploy HTTP client (shared by Stateful MCP + OpenCode middleware). * - * TRW routes (ENABLE_VIBECODING): + * 沙箱业务镜像 routes (ENABLE_VIBECODING): * POST /api/jobs/miniprogram-deploy * GET /api/jobs/:jobId * @@ -35,7 +35,7 @@ function trwErrorMessage(raw: unknown, httpStatus: number): string { return `HTTP ${httpStatus}` } -/** Start miniprogram deploy; adapts TRW Job / 202 body to legacy MCP envelope. */ +/** Start miniprogram deploy; adapts 沙箱业务镜像 Job / 202 body to legacy MCP envelope. */ export async function startTrwMiniprogramDeploy( http: TrwHttp, params: MiniprogramDeployRequest, diff --git a/packages/server/src/sandbox/wait-vite-ready.ts b/packages/server/src/sandbox/wait-vite-ready.ts index 6d74f4d..f940f58 100644 --- a/packages/server/src/sandbox/wait-vite-ready.ts +++ b/packages/server/src/sandbox/wait-vite-ready.ts @@ -1,6 +1,6 @@ /** - * Wait for TRW vite-dev-manager (vibecoding) — do not spawn a second dev server. - * TRW master has no /api/vibecoding/status; use /preview/ports + /preview/{port}/ probe only. + * 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' @@ -18,7 +18,7 @@ export interface ViteReadyResult { async function probePreviewPort(sandbox: SandboxInstance, port: number): Promise<boolean> { try { - // Hits TRW preview proxy → maybeEnsureViteDevForPreview (lazy unpack + vite start). + // Hits 沙箱业务镜像 preview proxy → maybeEnsureViteDevForPreview (lazy unpack + vite start). const res = await sandbox.request(`/preview/${port}/`, { method: 'GET', signal: AbortSignal.timeout(30_000), @@ -49,7 +49,7 @@ function progressMessage(ports: number[], targetPort: number, probed: boolean): } /** - * Poll TRW until vite preview is reachable. Relies on ensureViteDev() started at workspace init. + * Poll 沙箱业务镜像 until vite preview is reachable. Relies on ensureViteDev() started at workspace init. */ export async function waitForSandboxViteReady( sandbox: SandboxInstance, diff --git a/packages/server/src/util/skill-loader-override.ts b/packages/server/src/util/skill-loader-override.ts index fe48a76..b1a8c9b 100644 --- a/packages/server/src/util/skill-loader-override.ts +++ b/packages/server/src/util/skill-loader-override.ts @@ -200,7 +200,7 @@ async function sandboxReadFile(sandbox: SandboxConfig, filePath: string): Promis } } -/** Find SKILL.md paths under a directory via TRW /api/tools/glob. */ +/** Find SKILL.md paths under a directory via 沙箱业务镜像 /api/tools/glob. */ async function sandboxFindSkillMdPaths(sandbox: SandboxConfig, dirPath: string): Promise<string[]> { try { const res = await fetch(`${sandbox.url}/api/tools/glob`, { diff --git a/packages/web/src/components/terminal.tsx b/packages/web/src/components/terminal.tsx index 3a87092..e3bd262 100644 --- a/packages/web/src/components/terminal.tsx +++ b/packages/web/src/components/terminal.tsx @@ -1,5 +1,5 @@ /** - * Web terminal via TRW ttyd — virtual port 7681, proxied as /api/tasks/:id/preview/7681/. + * Web terminal via 沙箱业务镜像 ttyd — virtual port 7681, proxied as /api/tasks/:id/preview/7681/. */ import { forwardRef, useImperativeHandle } from 'react' diff --git a/packages/web/src/pages/admin/settings-page.tsx b/packages/web/src/pages/admin/settings-page.tsx index 26ad02b..09372b8 100644 --- a/packages/web/src/pages/admin/settings-page.tsx +++ b/packages/web/src/pages/admin/settings-page.tsx @@ -262,7 +262,7 @@ export function AdminSettingsPage() { 控制同一 CloudBase 环境下,多个任务是否共用同一个沙箱运行时实例(与上方「环境隔离」正交) </p> <p className="text-xs text-muted-foreground"> - 优先级:管理员设置(DB) > <code className="px-1 rounded bg-muted">SANDBOX_INSTANCE_MODE</code> > + 优先级:管理员设置(DB) > <code className="px-1 rounded bg-muted">WORKSPACE_ISOLATION</code> > 默认(provision=task 时为 isolated,否则 shared) </p> </div> diff --git a/scripts/init.mjs b/scripts/init.mjs index 1eb2d8c..f9a704d 100644 --- a/scripts/init.mjs +++ b/scripts/init.mjs @@ -882,7 +882,7 @@ ENCRYPTION_KEY=${crypto.randomBytes(32).toString('hex')} # Auth Providers NEXT_PUBLIC_AUTH_PROVIDERS=local -# Workspace isolation: each task gets its own SCF sandbox instance +# Sandbox instance mode: shared (one instance per env) | isolated (per task). Same env name as upstream main. WORKSPACE_ISOLATION=isolated # Rate Limiting @@ -1004,12 +1004,14 @@ GIT_ARCHIVE_REPO=${getPreserved('GIT_ARCHIVE_REPO')} GIT_ARCHIVE_USER=${getPreserved('GIT_ARCHIVE_USER')} GIT_ARCHIVE_TOKEN=${getPreserved('GIT_ARCHIVE_TOKEN')} -# ==================== Stateful Sandbox (AGS + TRW) ==================== +# ==================== Stateful Sandbox (AGS + 沙箱业务镜像) ==================== # TCB_API_KEY: CloudBase console API Key (gateway data plane) TCB_API_KEY=${get('TCB_API_KEY')} +ENABLE_AUTH_MODE=${get('ENABLE_AUTH_MODE', 'false')} +# TCB_ACCESS_TOKEN= # sit_* when ENABLE_AUTH_MODE=true STATEFUL_SANDBOX_IMAGE=${get('STATEFUL_SANDBOX_IMAGE', get('TCR_IMAGE'))} -SANDBOX_INSTANCE_MODE=${get('SANDBOX_INSTANCE_MODE', 'shared')} +WORKSPACE_ISOLATION=${get('WORKSPACE_ISOLATION', get('SANDBOX_INSTANCE_MODE', 'shared'))} # STATEFUL_TOOL_ID= # debug only # STATEFUL_SANDBOX_ID= # debug: pin instance From 72a238b85ea3fa46796eef93db828770c225c271 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Mon, 25 May 2026 21:51:08 +0800 Subject: [PATCH 16/29] style(server): format sandbox-config after pre-commit hook Co-authored-by: Cursor <cursoragent@cursor.com> --- packages/server/src/lib/sandbox-config.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/server/src/lib/sandbox-config.ts b/packages/server/src/lib/sandbox-config.ts index dca8fba..b43bdb8 100644 --- a/packages/server/src/lib/sandbox-config.ts +++ b/packages/server/src/lib/sandbox-config.ts @@ -72,8 +72,7 @@ export async function resolveSandboxInstanceMode(): Promise<{ source: SandboxInstanceModeSource envDefault: SandboxInstanceMode }> { - const envIsolation = - process.env.WORKSPACE_ISOLATION || process.env.SANDBOX_INSTANCE_MODE || '' + const envIsolation = process.env.WORKSPACE_ISOLATION || process.env.SANDBOX_INSTANCE_MODE || '' const envDefault = normalizeSandboxMode(envIsolation || BUILTIN_DEFAULT) try { From d530b8dfdc49a07ae43cf7311ad70de8a8b9ee94 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Tue, 26 May 2026 15:35:18 +0800 Subject: [PATCH 17/29] feat(stateful): dual sandbox/LLM status, delete-all, and sandbox TTL Split turn progress into a latched sandbox row (reuse/start/fail) and a separate LLM row so ready sandboxes no longer masquerade as model streaming. Add DELETE /api/tasks?action=all with shared-mode instance stop, SANDBOX_TTL_SECONDS for AGS timeouts, default WORKSPACE_ISOLATION=shared, and sidebar tooltips. Co-authored-by: Cursor <cursoragent@cursor.com> --- .env.example | 16 +- AGENTS.md | 2 +- README-zh.md | 1 - README.md | 1 - docs/setup.md | 6 +- packages/server/.env.example | 6 +- .../server/scripts/debug-start-instance.ts | 3 +- .../server/src/db/cloudbase/repositories.ts | 2 +- packages/server/src/routes/tasks.ts | 107 +++++++++-- .../src/sandbox/ensure-stateful-tool.ts | 3 +- .../src/sandbox/provider/stateful-provider.ts | 20 ++- .../src/sandbox/stateful-sandbox-ttl.ts | 29 +++ .../chat/agent-status-indicator.tsx | 128 +------------ .../src/components/chat/turn-status-lines.tsx | 150 ++++++++++++++++ packages/web/src/components/task-chat.tsx | 71 ++++++-- packages/web/src/components/task-details.tsx | 29 ++- packages/web/src/components/task-sidebar.tsx | 170 ++++++++---------- .../web/src/hooks/apply-session-update.ts | 74 +++++++- packages/web/src/hooks/use-chat-stream.ts | 5 +- .../web/src/lib/sandbox-instance-mode-copy.ts | 43 +++++ packages/web/src/lib/sandbox-status.ts | 131 ++++++++++++++ scripts/init.mjs | 5 +- 22 files changed, 702 insertions(+), 300 deletions(-) create mode 100644 packages/server/src/sandbox/stateful-sandbox-ttl.ts create mode 100644 packages/web/src/components/chat/turn-status-lines.tsx create mode 100644 packages/web/src/lib/sandbox-instance-mode-copy.ts create mode 100644 packages/web/src/lib/sandbox-status.ts diff --git a/.env.example b/.env.example index eefb655..bc78519 100644 --- a/.env.example +++ b/.env.example @@ -49,9 +49,12 @@ MAX_SANDBOX_DURATION=300 # ==================== Sandbox ==================== # 沙箱实例隔离(AGS;与 TCB_PROVISION_MODE 不是一回事): -# shared - 同一 envId 下多任务共享实例 -# isolated - 每 task 独立实例(默认) -WORKSPACE_ISOLATION=isolated +# shared - 同一 envId 下多任务共享实例(默认) +# isolated - 每 task 独立实例 +WORKSPACE_ISOLATION=shared + +# AGS 实例最长存活(秒),映射 StartSandboxInstance.Timeout / Tool DefaultTimeout(默认 1800 = 30m) +# SANDBOX_TTL_SECONDS=1800 # 沙箱数据面 gateway(写入 packages/server/.env,init 会生成) # TCB_API_KEY= @@ -63,10 +66,6 @@ WORKSPACE_ISOLATION=isolated # 沙箱业务镜像 URI(可选;不配则用代码内公开 TCR 默认) # STATEFUL_SANDBOX_IMAGE= -# 仅调试:跳过按 env 自动创建 Tool / 固定实例 -# STATEFUL_TOOL_ID= -# STATEFUL_SANDBOX_ID= - # Vite 原生错误 overlay 开关(创建沙箱时注入,需要重建沙箱生效) # 设为 false 可关闭预览中 Vite 自带的全屏错误遮罩,改由平台侧 banner 展示构建错误 # VITE_DEV_OVERLAY=false @@ -90,9 +89,6 @@ WORKSPACE_ISOLATION=isolated # GIT_ARCHIVE_USER= # GIT_ARCHIVE_TOKEN= -# Link 用户自有仓库(仓库 URL 在任务 UI 填写) -# GIT_PERSONAL_AUTH= - # ==================== Proxy (Optional) ==================== # http_proxy= diff --git a/AGENTS.md b/AGENTS.md index 507041e..494a73c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,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 一实例) ### 数据库 diff --git a/README-zh.md b/README-zh.md index d5bff40..28eac3e 100644 --- a/README-zh.md +++ b/README-zh.md @@ -274,7 +274,6 @@ MAX_SANDBOX_DURATION=300 ANTHROPIC_API_KEY= OPENAI_API_KEY= GEMINI_API_KEY= -GIT_PERSONAL_AUTH= ``` --- diff --git a/README.md b/README.md index d62bfcc..7de763b 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,6 @@ MAX_SANDBOX_DURATION=300 ANTHROPIC_API_KEY= OPENAI_API_KEY= GEMINI_API_KEY= -GIT_PERSONAL_AUTH= ``` --- diff --git a/docs/setup.md b/docs/setup.md index d702abb..7eeb101 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -131,8 +131,10 @@ flowchart TD | `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 同名;`SANDBOX_INSTANCE_MODE` 仍可读作兼容别名) | -| `GIT_ARCHIVE_*` / `GIT_PERSONAL_AUTH` | 否 | 归档与 Link;实例就绪后 `PUT /api/workspace/env` 注入(非 Start 时传 boot env) | +| `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 覆盖。 @@ -141,8 +143,6 @@ flowchart TD **小程序**:沙箱业务镜像 `/api/jobs/miniprogram-deploy` **默认开启**,无需 env 开关。 -**仅调试脚本**:`STATEFUL_TOOL_ID`、`STATEFUL_SANDBOX_ID`。 - **镜像**:须为 `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`) diff --git a/packages/server/.env.example b/packages/server/.env.example index d54895a..6408197 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -14,6 +14,7 @@ TCB_API_KEY= CODEBUDDY_API_KEY= # --- Sandbox (AGS + 沙箱业务镜像); first task may CreateSandboxTool once per env --- +# SANDBOX_TTL_SECONDS=1800 # AGS instance TTL (seconds); default 30m # WORKSPACE_ISOLATION=shared # STATEFUL_SANDBOX_IMAGE= # optional; default public TCR in code if unset # TCR_IMAGE= # from pnpm setup:tcr; used if STATEFUL_SANDBOX_IMAGE unset @@ -21,10 +22,5 @@ CODEBUDDY_API_KEY= # --- Optional features --- # TCB_PROVISION_MODE=shared # GIT_ARCHIVE_REPO= / GIT_ARCHIVE_TOKEN= / GIT_ARCHIVE_USER= -# GIT_PERSONAL_AUTH= # Link Git in UI; separate test repo from archive # Miniprogram deploy via 沙箱业务镜像 is enabled by default (no env flag). # ASK_USER_BASE_URL=http://127.0.0.1:3001 - -# --- Debug only (never in production) --- -# STATEFUL_TOOL_ID= -# STATEFUL_SANDBOX_ID= diff --git a/packages/server/scripts/debug-start-instance.ts b/packages/server/scripts/debug-start-instance.ts index 65e1ffc..3d23668 100644 --- a/packages/server/scripts/debug-start-instance.ts +++ b/packages/server/scripts/debug-start-instance.ts @@ -6,6 +6,7 @@ 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, @@ -61,7 +62,7 @@ async function main() { } const startParam: Record<string, unknown> = { ToolId: toolId, - Timeout: '30m', + Timeout: resolveAgsSandboxTimeout(), ...(customConfiguration ? { CustomConfiguration: customConfiguration } : {}), } if (!isStatefulAuthModeEnabled()) startParam.AuthMode = 'NONE' diff --git a/packages/server/src/db/cloudbase/repositories.ts b/packages/server/src/db/cloudbase/repositories.ts index 7371f1e..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<string, unknown>): 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, diff --git a/packages/server/src/routes/tasks.ts b/packages/server/src/routes/tasks.ts index f1fa909..f484814 100644 --- a/packages/server/src/routes/tasks.ts +++ b/packages/server/src/routes/tasks.ts @@ -8,10 +8,11 @@ import { createTaskLogger } from '../lib/task-logger' import { STATEFUL_WORKSPACE_ROOT, resolveSandboxConfig, + resolveSandboxInstanceMode, resolveSandboxModeForNewTask, backfillSandboxConfig, isValidSandboxInstanceMode, -} from '../lib/sandbox-config' +} from '../lib/sandbox-config.js' import { buildStatefulAcquireContext } from '../sandbox/acquire-context.js' import { proxyTaskPreview } from '../sandbox/preview-proxy.js' import { decrypt } from '../lib/crypto' @@ -22,6 +23,7 @@ import { deleteConversationViaSandbox, archiveToGit, getSandboxProvider, + statefulProvider, getTaskSandbox, runCommandInSandbox, downloadFileFromSandbox, @@ -424,6 +426,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) @@ -496,9 +508,21 @@ type DeleteTaskSuccess = { provisionFailed?: Awaited<ReturnType<typeof destroyProvisionedResources>>['failed'] } +async function cleanupSandboxAfterTaskDelete(existing: Task, envId: string): Promise<void> { + 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 }, ): Promise<DeleteTaskSuccess | { ok: false; failure: DeleteTaskFailure }> { const taskId = existing.id let provisionFailed: DeleteTaskSuccess['provisionFailed'] @@ -543,23 +567,17 @@ async function deleteTaskForUser( await getDb().tasks.softDelete(taskId) - void (async () => { + if (opts?.awaitSandbox) { try { - const sandbox = await getTaskSandbox(existing, envId).catch(() => null) - if (sandbox) { - if (existing.sandboxMode === 'isolated') { - const provider = getSandboxProvider() - if (provider.destroy) { - await provider.destroy(sandbox) - } - } else { - await deleteConversationViaSandbox(sandbox, envId, taskId, existing.sandboxCwd || undefined) - } - } + 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 { @@ -571,15 +589,72 @@ async function deleteTaskForUser( return { ok: true } } -// Bulk delete by status (sidebar: completed / failed / stopped) 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 + let deleted = 0 + const failures: Array<{ taskId: string; error: string; detail?: string }> = [] + + for (const task of toDelete) { + const result = await deleteTaskForUser(task, envId, { awaitSandbox: true }) + if (result.ok) { + deleted += 1 + 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, + }) + } + } + + let sharedSandboxStopped = false + if (instanceMode === 'shared' && deleted > 0) { + try { + sharedSandboxStopped = await statefulProvider.stopSharedEnvSandbox(envId) + } 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=completed,failed,stopped)' }, 400) + 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)) diff --git a/packages/server/src/sandbox/ensure-stateful-tool.ts b/packages/server/src/sandbox/ensure-stateful-tool.ts index beb7b36..46bb4f6 100644 --- a/packages/server/src/sandbox/ensure-stateful-tool.ts +++ b/packages/server/src/sandbox/ensure-stateful-tool.ts @@ -12,6 +12,7 @@ import { resolveStatefulImageRegistryType, resolveStatefulSandboxImage, } from './stateful-vibecoding-image.js' +import { resolveAgsSandboxTimeout } from './stateful-sandbox-ttl.js' export const STATEFUL_TOOL_SETTINGS_KEY = 'stateful_tool_id' @@ -130,7 +131,7 @@ async function createSandboxTool(envId: string): Promise<string> { }, }, NetworkConfiguration: { NetworkMode: 'PUBLIC' }, - DefaultTimeout: '30m', + DefaultTimeout: resolveAgsSandboxTimeout(), Description: `OpenVibeCoding stateful sandbox for env ${envId}`, } diff --git a/packages/server/src/sandbox/provider/stateful-provider.ts b/packages/server/src/sandbox/provider/stateful-provider.ts index 1f6d6d9..154c194 100644 --- a/packages/server/src/sandbox/provider/stateful-provider.ts +++ b/packages/server/src/sandbox/provider/stateful-provider.ts @@ -49,6 +49,7 @@ import { } 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 ──────────────────────────────────────────────────────────── @@ -238,7 +239,7 @@ async function callAgsManagerApi( } async function startStatefulInstance(cfg: StatefulRuntimeConfig, toolId: string): Promise<string> { - const startParam: Record<string, unknown> = { ToolId: toolId, Timeout: '30m' } + const startParam: Record<string, unknown> = { ToolId: toolId, Timeout: resolveAgsSandboxTimeout() } if (!cfg.enableAuthMode) startParam.AuthMode = 'NONE' const result = (await callAgsManagerApi('StartSandboxInstance', startParam, cfg)) as Record<string, unknown> const data = result?.data as Record<string, unknown> | undefined @@ -596,6 +597,23 @@ class StatefulProvider implements SandboxProvider { // 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<boolean> { + 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<void> { 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/web/src/components/chat/agent-status-indicator.tsx b/packages/web/src/components/chat/agent-status-indicator.tsx index f12e93c..aa832f0 100644 --- a/packages/web/src/components/chat/agent-status-indicator.tsx +++ b/packages/web/src/components/chat/agent-status-indicator.tsx @@ -1,126 +1,2 @@ -import { Loader2, Rocket, Sparkles, Hammer, Archive } from 'lucide-react' -import type { AgentPhaseName } from '@coder/shared' -import type { ComponentType } from 'react' - -/** - * AgentStatusIndicator — 代理执行阶段指示器(P4 前端) - * - * 对应官方反编译 05-tool-call-components.tsx 的 AgentStatusLine 组件。 - * 服务端通过 `sessionUpdate: 'agent_phase'` 事件推送阶段, - * 此处只负责把 {phase, toolName} 映射成一条"…中"样式的状态行。 - * - * 设计细节: - * - phase === null → 直接返回 null,避免空容器占位 - * - 图标按 phase 分色(准备中蓝 / 模型响应紫 / 工具执行橙 / 压缩中灰) - * - aria-live="polite" 让屏幕阅读器在 phase 切换时朗读 - */ - -interface AgentStatusIndicatorProps { - phase: AgentPhaseName | null - toolName?: string - /** 额外 className(如 padding / margin) */ - className?: string -} - -interface PhaseConfig { - Icon: ComponentType<{ className?: string }> - iconClass: string - /** 根据 toolName 动态生成文案的函数 */ - label: (toolName?: string) => string -} - -/** - * 工具名显示归一化:剥掉 `mcp__<server>__` 前缀,与 ToolCallCard 保持一致 - */ -function prettyToolName(name?: string): string { - if (!name) return '' - return name.replace(/^mcp__[^_]+__/, '') -} - -const PHASE_CONFIG: Record<AgentPhaseName, PhaseConfig> = { - preparing: { - Icon: Rocket, - iconClass: 'text-blue-500', - label: (toolName) => { - switch (toolName) { - case 'sandbox:template_resolve': - return '加载沙箱模板...' - case 'sandbox:template_bind': - return '绑定已有沙箱模板...' - case 'sandbox:template_create': - return '沙箱模板生成中(本环境仅首次)...' - case 'sandbox:template_warmup': - return '沙箱模板预热中...' - case 'sandbox:template_update': - return '同步沙箱模板镜像...' - case 'sandbox:instance_reuse_session': - return '复用本会话沙箱连接...' - case 'sandbox:instance_reuse_shared': - return '复用环境沙箱(多任务共享)...' - case 'sandbox:instance_reuse_task': - return '复用任务沙箱...' - case 'sandbox:instance_resume': - return '恢复沙箱实例...' - case 'sandbox:instance_start': - return '启动沙箱实例...' - case 'sandbox:reuse': - return '连接已有沙箱...' - case 'sandbox:create': - return '创建沙箱...' - case 'sandbox:wait_creating': - return '沙箱启动中...' - case 'sandbox:pull_image': - return '沙箱实例镜像拉取中...' - case 'sandbox:wait_ready': - return '等待沙箱就绪...' - case 'sandbox:init_mcp': - return '初始化工作空间...' - case 'sandbox:ready': - return '沙箱已就绪...' - case 'sandbox:error': - return '沙箱启动异常,回退中...' - default: - return '准备中...' - } - }, - }, - model_responding: { - Icon: Sparkles, - iconClass: 'text-primary', - label: () => '模型响应中...', - }, - tool_executing: { - Icon: Hammer, - iconClass: 'text-orange-500', - label: (toolName) => (toolName ? `执行 ${prettyToolName(toolName)} 中...` : '工具执行中...'), - }, - compacting: { - Icon: Archive, - iconClass: 'text-muted-foreground', - label: () => '正在压缩上下文...', - }, - idle: { - // idle 不会渲染,这里占位保证类型完备 - Icon: Loader2, - iconClass: '', - label: () => '', - }, -} - -export function AgentStatusIndicator({ phase, toolName, className }: AgentStatusIndicatorProps) { - if (!phase || phase === 'idle') return null - const cfg = PHASE_CONFIG[phase] - const text = cfg.label(toolName) - if (!text) return null - const { Icon } = cfg - - return ( - <div aria-live="polite" className={`flex items-center gap-2 text-xs text-muted-foreground ${className ?? ''}`}> - {/* 主图标:phase 主题色 */} - <Icon className={`h-3.5 w-3.5 flex-shrink-0 ${cfg.iconClass}`} /> - {/* 文案 + 旋转 Loader 的组合(Loader 表示"仍在进行中") */} - <span className="truncate">{text}</span> - <Loader2 className="h-3 w-3 animate-spin text-muted-foreground/60 flex-shrink-0" /> - </div> - ) -} +/** @deprecated Use `turn-status-lines` (`TurnStatusLines`) for sandbox + LLM dual-row status. */ +export { TurnStatusLines, TurnStatusLines as AgentStatusIndicator } from './turn-status-lines' diff --git a/packages/web/src/components/chat/turn-status-lines.tsx b/packages/web/src/components/chat/turn-status-lines.tsx new file mode 100644 index 0000000..a4084fe --- /dev/null +++ b/packages/web/src/components/chat/turn-status-lines.tsx @@ -0,0 +1,150 @@ +import { Loader2, Rocket, Sparkles, Hammer, Archive, CheckCircle2, XCircle } from 'lucide-react' +import type { AgentPhaseName } from '@coder/shared' +import type { AgentPhaseInfo } from '@/hooks/apply-session-update' +import { + sandboxPreparingLabel, + sandboxTerminalLabel, + refineOutcomeForMode, + type SandboxLaneState, +} from '@/lib/sandbox-status' + +interface TurnStatusLinesProps { + agentPhase: AgentPhaseInfo + sandboxMode?: 'shared' | 'isolated' | null + /** Turn still in flight (streaming or sending). */ + isActive: boolean + /** Assistant message already has visible content/parts. */ + hasAgentContent: boolean + className?: string +} + +function prettyToolName(name?: string): string { + if (!name) return '' + return name.replace(/^mcp__[^_]+__/, '') +} + +function StatusLine({ + icon: Icon, + iconClass, + label, + spinning = false, + muted = false, +}: { + icon: typeof Rocket + iconClass: string + label: string + spinning?: boolean + muted?: boolean +}) { + return ( + <div + className={`flex items-center gap-2 text-xs ${muted ? 'text-muted-foreground/70' : 'text-muted-foreground'}`} + aria-live="polite" + > + <Icon className={`h-3.5 w-3.5 flex-shrink-0 ${iconClass}`} /> + <span className="truncate">{label}</span> + {spinning && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground/60 flex-shrink-0" />} + </div> + ) +} + +function SandboxStatusLine({ + sandbox, + sandboxMode, +}: { + sandbox: SandboxLaneState + sandboxMode?: 'shared' | 'isolated' | null +}) { + if (sandbox.status === 'idle') return null + + if (sandbox.status === 'preparing') { + return ( + <StatusLine icon={Rocket} iconClass="text-blue-500" label={sandboxPreparingLabel(sandbox.toolName)} spinning /> + ) + } + + if (sandbox.status === 'failed') { + return <StatusLine icon={XCircle} iconClass="text-red-500/80" label={sandboxTerminalLabel('failed', sandboxMode)} /> + } + + const outcomeKey = refineOutcomeForMode(sandbox.outcomeKey ?? 'ready', sandbox.lastPrepareToolName, sandboxMode) + return ( + <StatusLine + icon={CheckCircle2} + iconClass="text-green-600 dark:text-green-500" + label={sandboxTerminalLabel(outcomeKey, sandboxMode)} + /> + ) +} + +function AgentActivityLine({ + phase, + toolName, + sandbox, + isActive, + hasAgentContent, +}: { + phase: AgentPhaseName | null + toolName?: string + sandbox: SandboxLaneState + isActive: boolean + hasAgentContent: boolean +}) { + if (!isActive) return null + + if (phase === 'tool_executing') { + const label = toolName ? `执行 ${prettyToolName(toolName)}…` : '工具执行中…' + return <StatusLine icon={Hammer} iconClass="text-orange-500" label={label} spinning /> + } + + if (phase === 'compacting') { + return <StatusLine icon={Archive} iconClass="text-muted-foreground" label="压缩上下文中…" spinning /> + } + + if (phase === 'model_responding') { + return <StatusLine icon={Sparkles} iconClass="text-primary" label="模型响应中…" spinning /> + } + + // Sandbox still preparing: no LLM row (avoid "正在生成回复" false positive). + if (sandbox.status === 'preparing') return null + + // Sandbox done but model has not started streaming yet. + if (sandbox.status === 'success' || sandbox.status === 'failed') { + if (!hasAgentContent && phase !== 'tool_executing') { + const label = sandbox.status === 'failed' ? '等待模型(受限模式)…' : '等待模型响应…' + return <StatusLine icon={Sparkles} iconClass="text-primary/70" label={label} spinning muted /> + } + } + + return null +} + +/** + * Two-row turn status: sandbox lifecycle (terminal: reuse / start / fail) + LLM activity. + */ +export function TurnStatusLines({ + agentPhase, + sandboxMode, + isActive, + hasAgentContent, + className, +}: TurnStatusLinesProps) { + const sandbox = agentPhase.sandbox + const showSandbox = sandbox.status !== 'idle' + const agentLine = AgentActivityLine({ + phase: agentPhase.phase, + toolName: agentPhase.toolName, + sandbox, + isActive, + hasAgentContent, + }) + + if (!showSandbox && !agentLine) return null + + return ( + <div className={`space-y-1 ${className ?? ''}`}> + {showSandbox && <SandboxStatusLine sandbox={sandbox} sandboxMode={sandboxMode} />} + {agentLine} + </div> + ) +} diff --git a/packages/web/src/components/task-chat.tsx b/packages/web/src/components/task-chat.tsx index 92a3f45..c94e333 100644 --- a/packages/web/src/components/task-chat.tsx +++ b/packages/web/src/components/task-chat.tsx @@ -14,7 +14,7 @@ import { ToolCallCard } from '@/components/chat/tool-call-card' import { SubagentCard } from '@/components/chat/subagent-card' import { AskUserForm } from '@/components/chat/ask-user-form' import { InterruptionCard } from '@/components/chat/interruption-card' -import { AgentStatusIndicator } from '@/components/chat/agent-status-indicator' +import { TurnStatusLines } from '@/components/chat/turn-status-lines' import { extractPlanContent } from '@/components/chat/plan-content' import { mdComponents } from '@/components/chat/markdown-block' import { useState, useEffect, useRef, useCallback } from 'react' @@ -1049,10 +1049,25 @@ export function TaskChat({ isLatestGroup && isLatestMessage && (isStreamingResponse || isSending) && - agentPhase?.phase && - agentPhase.phase !== 'idle' && ( + (agentPhase.sandbox.status !== 'idle' || + (agentPhase.phase && agentPhase.phase !== 'idle')) && ( <div className="px-2 pb-1"> - <AgentStatusIndicator phase={agentPhase.phase} toolName={agentPhase.toolName} /> + <TurnStatusLines + agentPhase={agentPhase} + sandboxMode={ + task.sandboxMode === 'isolated' || task.sandboxMode === 'shared' + ? task.sandboxMode + : 'shared' + } + isActive={isStreamingResponse || isSending} + hasAgentContent={ + !!agentMessage.content.trim() || + !!agentMessage.parts?.some( + (p) => + p.type === 'tool_call' || p.type === 'thinking' || (p.type === 'text' && p.text), + ) + } + /> </div> )} <div className="text-xs text-muted-foreground px-2"> @@ -1320,25 +1335,43 @@ export function TaskChat({ const userMessages = displayMessages.filter((m) => m.role === 'user') const isFirstMessage = userMessages.length === 1 const setupLogs = (task.logs || []).filter((log) => !log.message.startsWith('[SERVER]')).slice(-8) - if (isFirstMessage && setupLogs.length > 0) { + const sandboxLaneActive = (agentPhase?.sandbox.status ?? 'idle') !== 'idle' + if (isFirstMessage && (setupLogs.length > 0 || sandboxLaneActive)) { return ( <div className="mt-4"> <div className="text-xs px-2"> <div className="space-y-1"> - <div className="text-muted-foreground font-medium mb-2 flex items-center gap-2"> - <Loader2 className="h-3 w-3 animate-spin" /> - 正在设置沙箱... - </div> - <div className="space-y-0.5 pl-5"> - {setupLogs.map((log, idx) => ( - <div - key={idx} - className={`truncate ${idx === setupLogs.length - 1 ? 'text-foreground' : log.type === 'error' ? 'text-red-500/60' : log.type === 'success' ? 'text-green-500/60' : 'text-muted-foreground/60'}`} - > - {log.message} - </div> - ))} - </div> + {!readOnly && sandboxLaneActive && agentPhase ? ( + <div className="mb-2"> + <TurnStatusLines + agentPhase={agentPhase} + sandboxMode={ + task.sandboxMode === 'isolated' || task.sandboxMode === 'shared' + ? task.sandboxMode + : 'shared' + } + isActive={isStreamingResponse || isSending} + hasAgentContent={false} + /> + </div> + ) : ( + <div className="text-muted-foreground font-medium mb-2 flex items-center gap-2"> + <Loader2 className="h-3 w-3 animate-spin" /> + 环境准备中... + </div> + )} + {setupLogs.length > 0 && ( + <div className="space-y-0.5 pl-5"> + {setupLogs.map((log, idx) => ( + <div + key={idx} + className={`truncate ${idx === setupLogs.length - 1 ? 'text-foreground' : log.type === 'error' ? 'text-red-500/60' : log.type === 'success' ? 'text-green-500/60' : 'text-muted-foreground/60'}`} + > + {log.message} + </div> + ))} + </div> + )} <div className="text-right font-mono text-muted-foreground/50 mt-2"> {formatDuration(lastMessage.createdAt)} </div> diff --git a/packages/web/src/components/task-details.tsx b/packages/web/src/components/task-details.tsx index b11c16f..53a8d71 100644 --- a/packages/web/src/components/task-details.tsx +++ b/packages/web/src/components/task-details.tsx @@ -10,6 +10,11 @@ interface Connector { } import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { + deleteSingleTaskDialogBody, + deleteSingleTaskMenuHint, + type SandboxInstanceMode, +} from '@/lib/sandbox-instance-mode-copy' import { GitBranch, CheckCircle, @@ -265,12 +270,12 @@ export function TaskDetails({ const lastSandboxReadyRefetchRef = useRef(0) useEffect(() => { if (!isCodingModeForAutoFix || !onTaskRefetch) return - if (chatStream.agentPhase.toolName !== 'sandbox:ready') return + if (chatStream.agentPhase.sandbox.status !== 'success') return const ts = chatStream.agentPhase.timestamp if (ts <= lastSandboxReadyRefetchRef.current) return lastSandboxReadyRefetchRef.current = ts onTaskRefetch() - }, [chatStream.agentPhase.toolName, chatStream.agentPhase.timestamp, isCodingModeForAutoFix, onTaskRefetch]) + }, [chatStream.agentPhase.sandbox.status, chatStream.agentPhase.timestamp, isCodingModeForAutoFix, onTaskRefetch]) // Handle initial prompt (once) at this level const initialTriggered = useRef(false) @@ -289,6 +294,7 @@ export function TaskDetails({ const [refreshKey, setRefreshKey] = useState(0) const previousStatusRef = useRef<Task['status']>(task.status) const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [envSandboxInstanceMode, setEnvSandboxInstanceMode] = useState<SandboxInstanceMode>('shared') const [showTryAgainDialog, setShowTryAgainDialog] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const [isTryingAgain, setIsTryingAgain] = useState(false) @@ -307,6 +313,15 @@ export function TaskDetails({ const [tryAgainMaxDuration, setTryAgainMaxDuration] = useState(task.maxDuration || maxSandboxDuration) const [tryAgainKeepAlive, setTryAgainKeepAlive] = useState(task.keepAlive || false) const [tryAgainEnableBrowser, setTryAgainEnableBrowser] = useState(task.enableBrowser || false) + + useEffect(() => { + void fetch('/api/tasks/sandbox-policy', { credentials: 'include' }) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { sandboxInstanceMode?: string } | null) => { + setEnvSandboxInstanceMode(data?.sandboxInstanceMode === 'isolated' ? 'isolated' : 'shared') + }) + .catch(() => setEnvSandboxInstanceMode('shared')) + }, []) const [tryAgainAgentModels, setTryAgainAgentModels] = useState<Record<string, Array<{ id: string; name: string }>>>( {}, ) @@ -2110,7 +2125,11 @@ export function TaskDetails({ <GitBranch className="h-4 w-4 mr-2" /> 关联 Git 仓库 </DropdownMenuItem> - <DropdownMenuItem onClick={() => setShowDeleteDialog(true)} className="text-red-600"> + <DropdownMenuItem + onClick={() => setShowDeleteDialog(true)} + className="text-red-600" + title={deleteSingleTaskMenuHint(task.sandboxMode, envSandboxInstanceMode)} + > <Trash2 className="h-4 w-4 mr-2" /> 删除任务 </DropdownMenuItem> @@ -3608,9 +3627,9 @@ export function TaskDetails({ <AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> <AlertDialogContent> <AlertDialogHeader> - <AlertDialogTitle>Delete Task</AlertDialogTitle> + <AlertDialogTitle>删除任务</AlertDialogTitle> <AlertDialogDescription> - Are you sure you want to delete this task? This action cannot be undone. + {deleteSingleTaskDialogBody(task.sandboxMode, envSandboxInstanceMode)} </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> diff --git a/packages/web/src/components/task-sidebar.tsx b/packages/web/src/components/task-sidebar.tsx index c5b40a1..2acc3e4 100644 --- a/packages/web/src/components/task-sidebar.tsx +++ b/packages/web/src/components/task-sidebar.tsx @@ -15,8 +15,14 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' -import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { + deleteAllTasksDialogBody, + deleteAllTasksTooltip, + newTaskTooltip, + type SandboxInstanceMode, +} from '@/lib/sandbox-instance-mode-copy' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { useState, useMemo, useEffect, useRef, useCallback } from 'react' import { toast } from 'sonner' @@ -98,9 +104,7 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { const githubConnection = useAtomValue(githubConnectionAtom) const [isDeleting, setIsDeleting] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [deleteCompleted, setDeleteCompleted] = useState(true) - const [deleteFailed, setDeleteFailed] = useState(true) - const [deleteStopped, setDeleteStopped] = useState(true) + const [sandboxInstanceMode, setSandboxInstanceMode] = useState<SandboxInstanceMode>('shared') const [activeTab, setActiveTab] = useState<TabType>('tasks') // State for repos from API @@ -117,6 +121,18 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { const [searchHasMore, setSearchHasMore] = useState(false) const loadMoreRef = useRef<HTMLDivElement>(null) + const activeTaskCount = useMemo(() => tasks.filter((t) => !t.deletedAt).length, [tasks]) + + useEffect(() => { + if (!session.user) return + void fetch('/api/tasks/sandbox-policy', { credentials: 'include' }) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { sandboxInstanceMode?: string } | null) => { + setSandboxInstanceMode(data?.sandboxInstanceMode === 'isolated' ? 'isolated' : 'shared') + }) + .catch(() => setSandboxInstanceMode('shared')) + }, [session.user]) + // Close sidebar on mobile when clicking any link const handleLinkClick = () => { if (typeof window !== 'undefined' && window.innerWidth < 1024) { @@ -321,35 +337,33 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { } } - const handleDeleteTasks = async () => { - if (!deleteCompleted && !deleteFailed && !deleteStopped) { - toast.error('Please select at least one task type to delete') - return - } + const handleDeleteAllTasks = async () => { + if (activeTaskCount === 0) return setIsDeleting(true) try { - const actions = [] - if (deleteCompleted) actions.push('completed') - if (deleteFailed) actions.push('failed') - if (deleteStopped) actions.push('stopped') - - const response = await fetch(`/api/tasks?action=${actions.join(',')}`, { + const response = await fetch('/api/tasks?action=all', { method: 'DELETE', + credentials: 'include', }) if (response.ok) { const result = await response.json() - toast.success(result.message) + const failed = Array.isArray(result.failed) ? result.failed.length : 0 + if (failed > 0) { + toast.warning(`${result.message ?? '已删除'}(${failed} 项警告/失败)`) + } else { + toast.success(result.message ?? '已删除全部任务') + } await refreshTasks() setShowDeleteDialog(false) } else { const error = await response.json() - toast.error(error.error || 'Failed to delete tasks') + toast.error(error.error || '删除全部任务失败') } } catch (error) { - console.error('Error deleting tasks:', error) - toast.error('Failed to delete tasks') + console.error('Error deleting all tasks:', error) + toast.error('删除全部任务失败') } finally { setIsDeleting(false) } @@ -426,8 +440,8 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { </Link> </div> <div className="border-t mb-2" /> - <div className="mb-2"> - <Link to="/" onClick={handleLinkClick}> + <div className="mb-2 flex gap-1.5"> + <Link to="/" onClick={handleLinkClick} className="flex-1 min-w-0"> <Button variant="outline" size="sm" className="w-full h-8 text-xs"> <Plus className="h-3.5 w-3.5 mr-2" /> 新建任务 @@ -484,32 +498,41 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { <div className="border-t mb-2" /> - {/* New Task Button */} - <div className="mb-2"> - <Link to="/" onClick={handleLinkClick}> - <Button variant="outline" size="sm" className="w-full h-8 text-xs"> - <Plus className="h-3.5 w-3.5 mr-2" /> - 新建任务 - </Button> - </Link> - </div> - - {/* Tasks header with delete */} - {/* <div className="mb-2"> - <div className="flex items-center justify-between"> - <span className="text-xs font-medium text-muted-foreground px-1">任务列表</span> - <Button - variant="ghost" - size="sm" - className="h-6 w-6 p-0" - onClick={() => setShowDeleteDialog(true)} - disabled={isDeleting || tasks.length === 0} - title="删除任务" - > - <Trash2 className="h-3.5 w-3.5" /> - </Button> + {/* New task + delete all */} + <TooltipProvider delayDuration={300}> + <div className="mb-2 flex gap-1.5"> + <Tooltip> + <TooltipTrigger asChild> + <Link to="/" onClick={handleLinkClick} className="flex-1 min-w-0"> + <Button variant="outline" size="sm" className="w-full h-8 text-xs"> + <Plus className="h-3.5 w-3.5 mr-2" /> + 新建任务 + </Button> + </Link> + </TooltipTrigger> + <TooltipContent side="bottom" className="max-w-[260px]"> + {newTaskTooltip(sandboxInstanceMode)} + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + className="h-8 w-8 p-0 shrink-0" + disabled={isDeleting || activeTaskCount === 0} + onClick={() => setShowDeleteDialog(true)} + aria-label="删除全部任务" + > + <Trash2 className="h-3.5 w-3.5" /> + </Button> + </TooltipTrigger> + <TooltipContent side="bottom" className="max-w-[260px]"> + {deleteAllTasksTooltip(sandboxInstanceMode, activeTaskCount)} + </TooltipContent> + </Tooltip> </div> - </div> */} + </TooltipProvider> {/* Tasks Tab Content */} {activeTab === 'tasks' && ( @@ -759,62 +782,19 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { <AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> <AlertDialogContent> <AlertDialogHeader> - <AlertDialogTitle>Delete Tasks</AlertDialogTitle> + <AlertDialogTitle>删除全部任务</AlertDialogTitle> <AlertDialogDescription> - Select which types of tasks you want to delete. This action cannot be undone. + {deleteAllTasksDialogBody(sandboxInstanceMode, activeTaskCount)} </AlertDialogDescription> </AlertDialogHeader> - <div className="py-4"> - <div className="space-y-4"> - <div className="flex items-center space-x-2"> - <Checkbox - id="delete-completed" - checked={deleteCompleted} - onCheckedChange={(checked) => setDeleteCompleted(checked === true)} - /> - <label - htmlFor="delete-completed" - className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" - > - Delete Completed Tasks - </label> - </div> - <div className="flex items-center space-x-2"> - <Checkbox - id="delete-failed" - checked={deleteFailed} - onCheckedChange={(checked) => setDeleteFailed(checked === true)} - /> - <label - htmlFor="delete-failed" - className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" - > - Delete Failed Tasks - </label> - </div> - <div className="flex items-center space-x-2"> - <Checkbox - id="delete-stopped" - checked={deleteStopped} - onCheckedChange={(checked) => setDeleteStopped(checked === true)} - /> - <label - htmlFor="delete-stopped" - className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" - > - Delete Stopped Tasks - </label> - </div> - </div> - </div> <AlertDialogFooter> - <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogCancel>取消</AlertDialogCancel> <AlertDialogAction - onClick={handleDeleteTasks} - disabled={isDeleting || (!deleteCompleted && !deleteFailed && !deleteStopped)} + onClick={handleDeleteAllTasks} + disabled={isDeleting || activeTaskCount === 0} className="bg-red-600 hover:bg-red-700" > - {isDeleting ? 'Deleting...' : 'Delete Tasks'} + {isDeleting ? (sandboxInstanceMode === 'isolated' ? '正在逐个删除…' : '正在删除…') : '删除全部'} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> diff --git a/packages/web/src/hooks/apply-session-update.ts b/packages/web/src/hooks/apply-session-update.ts index b38f4c2..b6b42fe 100644 --- a/packages/web/src/hooks/apply-session-update.ts +++ b/packages/web/src/hooks/apply-session-update.ts @@ -16,6 +16,12 @@ import type { Dispatch, SetStateAction, MutableRefObject } from 'react' import type { ExtendedSessionUpdate, AgentPhaseName } from '@coder/shared' import type { TaskMessage, ToolConfirmData, DeploymentInfo, ArtifactInfo } from '@/types/task-chat' import { extractPlanContent } from '@/components/chat/plan-content' +import { + IDLE_SANDBOX_LANE, + isSandboxToolName, + resolveSandboxOutcomeKey, + type SandboxLaneState, +} from '@/lib/sandbox-status' type StreamPhase = 'idle' | 'streaming' | 'waiting_for_interaction' @@ -42,6 +48,64 @@ export interface AgentPhaseInfo { phase: AgentPhaseName | null toolName?: string timestamp: number + sandbox: SandboxLaneState +} + +export const IDLE_AGENT_PHASE: AgentPhaseInfo = { + phase: null, + timestamp: 0, + sandbox: IDLE_SANDBOX_LANE, +} + +function applyAgentPhaseEvent( + prev: AgentPhaseInfo, + nextPhase: AgentPhaseName | null, + nextToolName?: string, + nextTs = Date.now(), +): AgentPhaseInfo { + if (prev.timestamp > nextTs) return prev + + if (isSandboxToolName(nextToolName)) { + const tool = nextToolName! + let sandbox: SandboxLaneState = { ...prev.sandbox } + + if (tool === 'sandbox:error') { + sandbox = { + status: 'failed', + toolName: tool, + outcomeKey: 'failed', + lastPrepareToolName: prev.sandbox.lastPrepareToolName, + } + } else if (tool === 'sandbox:ready') { + const last = prev.sandbox.lastPrepareToolName + const outcomeKey = last === 'sandbox:init_mcp' ? 'workspace_ready' : resolveSandboxOutcomeKey(last) + sandbox = { + status: 'success', + outcomeKey, + lastPrepareToolName: last, + } + } else { + sandbox = { + status: 'preparing', + toolName: tool, + lastPrepareToolName: tool, + } + } + + return { + phase: prev.phase, + toolName: prev.toolName, + timestamp: nextTs, + sandbox, + } + } + + return { + phase: nextPhase, + toolName: nextToolName, + timestamp: nextTs, + sandbox: prev.sandbox, + } } export interface ApplySessionUpdateCtx { @@ -342,18 +406,10 @@ export function applySessionUpdate(ctx: ApplySessionUpdateCtx): void { break case 'agent_phase': { - // P4: 代理执行阶段上报。服务端在关键边界(preparing/model_responding/tool_executing/ - // compacting/idle)推送,前端只负责把 phase + toolName 映射到状态指示器。 - // - // 同一 turn 内服务端已做过去重(lastEmittedPhase),这里直接覆盖即可; - // reconnect 场景若有乱序事件,用 timestamp 保证后到的旧事件不覆盖新 phase。 const nextPhase = (u.phase ?? null) as AgentPhaseName | null const nextToolName = typeof u.toolName === 'string' ? u.toolName : undefined const nextTs = typeof u.timestamp === 'number' ? u.timestamp : Date.now() - setAgentPhase((prev) => { - if (prev.timestamp > nextTs) return prev - return { phase: nextPhase, toolName: nextToolName, timestamp: nextTs } - }) + setAgentPhase((prev) => applyAgentPhaseEvent(prev, nextPhase, nextToolName, nextTs)) break } } diff --git a/packages/web/src/hooks/use-chat-stream.ts b/packages/web/src/hooks/use-chat-stream.ts index a3bdace..67829af 100644 --- a/packages/web/src/hooks/use-chat-stream.ts +++ b/packages/web/src/hooks/use-chat-stream.ts @@ -17,10 +17,10 @@ import type { ExtendedSessionUpdate, PermissionAction, AgentPermissionMode } fro import type { TaskMessage, AskUserQuestionData, ToolConfirmData, DeploymentInfo, ArtifactInfo } from '@/types/task-chat' import { planModeAtomFamily } from '@/lib/atoms/plan-mode' import { AcpClient } from '@/lib/acp' -import { applySessionUpdate, type AgentPhaseInfo } from './apply-session-update' +import { applySessionUpdate, IDLE_AGENT_PHASE, type AgentPhaseInfo } from './apply-session-update' /** Agent 执行阶段的空闲态;Hook 初始化与 turn 结束时复位用 */ -const IDLE_PHASE: AgentPhaseInfo = { phase: null, timestamp: 0 } +const IDLE_PHASE: AgentPhaseInfo = IDLE_AGENT_PHASE // ─── Stream Phase ───────────────────────────────────────────────────── @@ -140,6 +140,7 @@ export function useChatStream(taskId: string, options: UseChatStreamOptions = {} phaseRef.current = 'streaming' setIsSending(true) setIsStreamingResponse(true) + setAgentPhase(IDLE_PHASE) }, []) const exitStreaming = useCallback(async () => { diff --git a/packages/web/src/lib/sandbox-instance-mode-copy.ts b/packages/web/src/lib/sandbox-instance-mode-copy.ts new file mode 100644 index 0000000..a2873a1 --- /dev/null +++ b/packages/web/src/lib/sandbox-instance-mode-copy.ts @@ -0,0 +1,43 @@ +export type SandboxInstanceMode = 'shared' | 'isolated' + +export function newTaskTooltip(mode: SandboxInstanceMode): string { + if (mode === 'isolated') { + return '当前:隔离实例模式。每个任务使用独立沙箱实例;新建任务会为本任务启动实例,或复用该任务已绑定的实例。' + } + return '当前:共享实例模式。同环境下多任务共用一台沙箱实例;新建任务会复用已有实例(无则启动一台),工作区共盘 /home/user。' +} + +export function deleteAllTasksTooltip(mode: SandboxInstanceMode, taskCount: number): string { + if (taskCount === 0) return '当前没有可删除的任务' + if (mode === 'isolated') { + return `当前:隔离实例模式。将删除全部 ${taskCount} 个任务,并逐个停止对应沙箱实例;任务较多时可能较慢。` + } + return `当前:共享实例模式。将删除全部 ${taskCount} 个任务,并停止本环境共享沙箱实例以回收计算资源。` +} + +export function deleteAllTasksDialogBody(mode: SandboxInstanceMode, taskCount: number): string { + if (mode === 'isolated') { + return `将永久删除 ${taskCount} 个任务。每个任务绑定的沙箱实例会依次停止并销毁,任务较多时请耐心等待。此操作不可撤销。` + } + return `将永久删除 ${taskCount} 个任务,并停止本环境的共享沙箱实例(同环境下其他用户也无法再使用该实例)。此操作不可撤销。` +} + +export function deleteSingleTaskMenuHint( + taskSandboxMode: SandboxInstanceMode | null | undefined, + fallbackMode: SandboxInstanceMode, +): string { + const mode = taskSandboxMode === 'isolated' || taskSandboxMode === 'shared' ? taskSandboxMode : fallbackMode + if (mode === 'isolated') return '隔离模式:将销毁本任务专用沙箱实例' + return '共享模式:仅删任务,环境沙箱实例保留' +} + +export function deleteSingleTaskDialogBody( + taskSandboxMode: SandboxInstanceMode | null | undefined, + fallbackMode: SandboxInstanceMode, +): string { + const mode = taskSandboxMode === 'isolated' || taskSandboxMode === 'shared' ? taskSandboxMode : fallbackMode + if (mode === 'isolated') { + return '当前:隔离实例模式。删除本任务将停止并销毁该任务专用的沙箱实例。此操作不可撤销。' + } + return '当前:共享实例模式。仅删除本任务记录;环境共享沙箱实例会继续运行,供其他任务使用。此操作不可撤销。' +} diff --git a/packages/web/src/lib/sandbox-status.ts b/packages/web/src/lib/sandbox-status.ts new file mode 100644 index 0000000..a2fdb62 --- /dev/null +++ b/packages/web/src/lib/sandbox-status.ts @@ -0,0 +1,131 @@ +/** + * Sandbox lane labels for dual-row turn status UI. + * + * Server phases (stateful-provider, shared vs isolated): + * + * | Layer | shared + no instance | shared + RUNNING instance | shared + PAUSED | + * |-------|----------------------|---------------------------|-----------------| + * | Tool | template_* → instance_start → pull_image | template_* → instance_reuse_shared | instance_resume | + * | Cache | — | instance_reuse_session (same Node process) | same | + * + * | Layer | isolated + no task.sandboxId | isolated + bound RUNNING | isolated + PAUSED | + * |-------|------------------------------|--------------------------|-------------------| + * | Tool | template_* → instance_start | instance_reuse_task | instance_resume | + * + * Both modes then: wait_ready → ready → init_mcp → ready (workspace). + */ + +export type SandboxLaneStatus = 'idle' | 'preparing' | 'success' | 'failed' + +export type SandboxOutcomeKey = + | 'reused_shared' + | 'reused_task' + | 'reused_session' + | 'started_shared' + | 'started_isolated' + | 'resumed_shared' + | 'resumed_isolated' + | 'ready' + | 'workspace_ready' + | 'failed' + +export interface SandboxLaneState { + status: SandboxLaneStatus + /** Current or last preparing sub-phase (`sandbox:…`). */ + toolName?: string + /** Set when status becomes success. */ + outcomeKey?: SandboxOutcomeKey + /** Last preparing sub-phase before a `sandbox:ready` latch. */ + lastPrepareToolName?: string +} + +export const IDLE_SANDBOX_LANE: SandboxLaneState = { status: 'idle' } + +export function isSandboxToolName(toolName?: string): boolean { + return typeof toolName === 'string' && toolName.startsWith('sandbox:') +} + +const PREPARING_LABELS: Record<string, string> = { + '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 sandboxPreparingLabel(toolName?: string): string { + if (!toolName) return '准备沙箱环境…' + return PREPARING_LABELS[toolName] ?? '准备沙箱环境…' +} + +export function resolveSandboxOutcomeKey(lastPrepareToolName?: string): SandboxOutcomeKey { + switch (lastPrepareToolName) { + case 'sandbox:instance_reuse_shared': + return 'reused_shared' + case 'sandbox:instance_reuse_task': + return 'reused_task' + case 'sandbox:instance_reuse_session': + return 'reused_session' + case 'sandbox:instance_resume': + return 'resumed_shared' + case 'sandbox:instance_start': + return 'started_shared' + default: + return 'ready' + } +} + +export function sandboxTerminalLabel( + outcomeKey: SandboxOutcomeKey, + sandboxMode?: 'shared' | 'isolated' | null, +): string { + switch (outcomeKey) { + case 'reused_shared': + return '环境共享沙箱已复用' + case 'reused_task': + return '本任务沙箱已复用' + case 'reused_session': + return '沙箱连接已复用(本会话)' + case 'resumed_shared': + return sandboxMode === 'isolated' ? '任务沙箱已恢复' : '环境共享沙箱已恢复' + case 'resumed_isolated': + return '任务沙箱已恢复' + case 'started_shared': + return '环境共享沙箱已启动并就绪' + case 'started_isolated': + return '任务沙箱已启动并就绪' + case 'workspace_ready': + return '沙箱与工作区已就绪' + case 'failed': + return '沙箱未就绪(受限模式)' + case 'ready': + default: + return '沙箱已就绪' + } +} + +/** Refine generic `instance_start` / `instance_resume` labels using task sandbox mode. */ +export function refineOutcomeForMode( + outcomeKey: SandboxOutcomeKey, + lastPrepareToolName: string | undefined, + sandboxMode?: 'shared' | 'isolated' | null, +): SandboxOutcomeKey { + if (lastPrepareToolName === 'sandbox:instance_start') { + return sandboxMode === 'isolated' ? 'started_isolated' : 'started_shared' + } + if (lastPrepareToolName === 'sandbox:instance_resume') { + return sandboxMode === 'isolated' ? 'resumed_isolated' : 'resumed_shared' + } + return outcomeKey +} diff --git a/scripts/init.mjs b/scripts/init.mjs index f9a704d..dd919d1 100644 --- a/scripts/init.mjs +++ b/scripts/init.mjs @@ -883,7 +883,7 @@ ENCRYPTION_KEY=${crypto.randomBytes(32).toString('hex')} NEXT_PUBLIC_AUTH_PROVIDERS=local # Sandbox instance mode: shared (one instance per env) | isolated (per task). Same env name as upstream main. -WORKSPACE_ISOLATION=isolated +WORKSPACE_ISOLATION=shared # Rate Limiting MAX_MESSAGES_PER_DAY=50 @@ -1012,8 +1012,7 @@ ENABLE_AUTH_MODE=${get('ENABLE_AUTH_MODE', 'false')} # TCB_ACCESS_TOKEN= # sit_* when ENABLE_AUTH_MODE=true STATEFUL_SANDBOX_IMAGE=${get('STATEFUL_SANDBOX_IMAGE', get('TCR_IMAGE'))} WORKSPACE_ISOLATION=${get('WORKSPACE_ISOLATION', get('SANDBOX_INSTANCE_MODE', 'shared'))} -# STATEFUL_TOOL_ID= # debug only -# STATEFUL_SANDBOX_ID= # debug: pin instance +SANDBOX_TTL_SECONDS=${get('SANDBOX_TTL_SECONDS', '1800')} # ==================== GitHub OAuth (Optional) ==================== From b8859a95ad80f639eaacce623a2b5e34dfea02e2 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Tue, 26 May 2026 18:36:12 +0800 Subject: [PATCH 18/29] fix(stateful): ttyd terminal via TRW preview proxy Route virtual port 7681 through TRW gateway preview with correct WS handshake (tty protocol, binary frames, gateway path). Limit AGS tool ports to 9000/49983, add terminal-health, and improve Logs terminal UX with sandbox progress messages in the task stream. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../server/scripts/describe-stateful-tool.ts | 9 +- packages/server/scripts/probe-gateway-port.ts | 86 ++---- .../scripts/update-stateful-tool-ports.ts | 64 ++-- .../src/agent/cloudbase-agent.service.ts | 28 ++ packages/server/src/agent/event-buffer.ts | 1 + .../server/src/agent/runtime/base-runtime.ts | 21 +- packages/server/src/routes/tasks.ts | 30 ++ .../sandbox/__tests__/preview-proxy.test.ts | 7 +- .../__tests__/preview-ws-proxy.test.ts | 31 ++ .../__tests__/ttyd-gateway-port.test.ts | 16 + .../src/sandbox/ensure-stateful-tool.ts | 4 +- packages/server/src/sandbox/preview-proxy.ts | 13 +- .../server/src/sandbox/preview-ws-proxy.ts | 228 ++++++++++---- .../src/sandbox/stateful-vibecoding-image.ts | 2 +- .../server/src/sandbox/ttyd-gateway-port.ts | 88 ++++++ packages/server/src/sandbox/ttyd-preview.ts | 68 ++++ packages/shared/src/index.ts | 1 + packages/shared/src/sandbox-log-messages.ts | 41 +++ packages/shared/src/types/agent.ts | 3 + packages/web/src/components/logs-pane.tsx | 292 +++++++++--------- .../web/src/components/task-page-client.tsx | 21 +- packages/web/src/components/terminal.tsx | 188 ++++++++++- .../web/src/hooks/apply-session-update.ts | 15 +- packages/web/src/hooks/use-chat-stream.ts | 34 +- packages/web/src/lib/atoms/stream-logs.ts | 6 + packages/web/vite.config.ts | 6 + 26 files changed, 993 insertions(+), 310 deletions(-) create mode 100644 packages/server/src/sandbox/__tests__/preview-ws-proxy.test.ts create mode 100644 packages/server/src/sandbox/__tests__/ttyd-gateway-port.test.ts create mode 100644 packages/server/src/sandbox/ttyd-gateway-port.ts create mode 100644 packages/server/src/sandbox/ttyd-preview.ts create mode 100644 packages/shared/src/sandbox-log-messages.ts create mode 100644 packages/web/src/lib/atoms/stream-logs.ts diff --git a/packages/server/scripts/describe-stateful-tool.ts b/packages/server/scripts/describe-stateful-tool.ts index 45d7331..4dfb04a 100644 --- a/packages/server/scripts/describe-stateful-tool.ts +++ b/packages/server/scripts/describe-stateful-tool.ts @@ -55,8 +55,13 @@ async function main() { for (const p of ports) { console.log(` - ${p.Name ?? '?'}: ${p.Port} (${p.Protocol ?? 'TCP'})`) } - const has5173 = ports.some((p) => p.Port === 5173) - console.log('vite 5173 declared:', has5173 ? 'yes' : 'NO — gateway E2b-Sandbox-Port:5173 will 500') + 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) => { diff --git a/packages/server/scripts/probe-gateway-port.ts b/packages/server/scripts/probe-gateway-port.ts index 169265e..c5a0760 100644 --- a/packages/server/scripts/probe-gateway-port.ts +++ b/packages/server/scripts/probe-gateway-port.ts @@ -1,61 +1,43 @@ -/** - * 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 { readFileSync, existsSync } from 'node:fs' +import { resolve, dirname } 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') }) - -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 __dirname = dirname(fileURLToPath(import.meta.url)) +const envPath = resolve(__dirname, '../.env') +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() + if (!process.env[k]) process.env[k] = t.slice(eq + 1).trim() + } +} - const target = - mode === 'trw-preview' - ? buildTrwPreviewGatewayTarget({ - envId, - sandboxId, - tcbApiKey, - vitePort: port, - subpath: path === '/' ? '/' : path, - accessToken, - }) - : buildGatewayTarget({ envId, sandboxId, tcbApiKey, port, path, accessToken }) +const taskId = process.argv[2] || '1uup1280sermpmd7e6u' - 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') - } +async function main() { + const { getDb } = await import('../src/db/index.js') + const { getTaskSandbox } = await import('../src/sandbox/task-sandbox.js') + const { resolveGatewayPreviewPort } = await import('../src/sandbox/ttyd-gateway-port.js') + const { TTYD_VIRTUAL_PORT } = await import('../src/sandbox/ttyd-preview.js') + const task = await getDb().tasks.findById(taskId) + const sandbox = await getTaskSandbox(task!, process.env.TCB_ENV_ID || '', { + isCodingMode: task!.mode === 'coding', + }) + if (!sandbox) throw new Error('no sandbox') + const portsRes = await sandbox.request('/preview/ports') + const portsJson = await portsRes.json() + const gw = await resolveGatewayPreviewPort(sandbox, TTYD_VIRTUAL_PORT) + const auth = await sandbox.getAuthHeaders() + const url = `${sandbox.baseUrl}/preview/${gw}/` + const res = await fetch(url, { headers: auth }) + const text = await res.text() + console.log(JSON.stringify({ gw, portsJson, upstream: { status: res.status, ttyd: text.includes('ttyd') } }, null, 2)) } main().catch((e) => { - console.error(e) + console.error((e as Error).message) process.exit(1) }) diff --git a/packages/server/scripts/update-stateful-tool-ports.ts b/packages/server/scripts/update-stateful-tool-ports.ts index 2bb7923..f204d67 100644 --- a/packages/server/scripts/update-stateful-tool-ports.ts +++ b/packages/server/scripts/update-stateful-tool-ports.ts @@ -1,7 +1,7 @@ /** - * Merge vite/ttyd ports into an existing stateful SDT (idempotent). + * Set AGS tool Ports to TRW + envd only (idempotent). * - * pnpm exec tsx scripts/update-stateful-tool-ports.ts + * STATEFUL_TOOL_ID=... pnpm exec tsx scripts/update-stateful-tool-ports.ts */ import { config } from 'dotenv' @@ -11,12 +11,10 @@ import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) config({ path: resolve(here, '../.env') }) -const DESIRED_PORTS = [ - { Name: 'p9000', Protocol: 'TCP', Port: 9000 }, - { Name: 'p49983', Protocol: 'TCP', Port: 49983 }, - { Name: 'p7681', Protocol: 'TCP', Port: 7681 }, - { Name: 'p5173', Protocol: 'TCP', Port: 5173 }, - { Name: 'p3000', Protocol: 'TCP', Port: 3000 }, +/** 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<string, unknown>) { @@ -42,28 +40,35 @@ async function callAgs(action: string, param: Record<string, unknown>) { return ags.request(action, param) } -function mergePorts( - existing: Array<{ Name?: string; Port?: number; Protocol?: string }>, -): Array<{ Name: string; Protocol: string; Port: number }> { - const byPort = new Map<number, { Name: string; Protocol: string; Port: number }>() - for (const p of existing) { - if (typeof p.Port === 'number') { - byPort.set(p.Port, { - Name: p.Name || `p${p.Port}`, - Protocol: p.Protocol || 'TCP', - Port: p.Port, - }) - } - } - for (const p of DESIRED_PORTS) { - byPort.set(p.Port, p) +async function resolveToolId(): Promise<string> { + 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 } - return [...byPort.values()].sort((a, b) => a.Port - b.Port) + + 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 = process.env.STATEFUL_TOOL_ID || '' - if (!toolId) throw new Error('STATEFUL_TOOL_ID required') + const toolId = await resolveToolId() const list = (await callAgs('DescribeSandboxToolList', { ToolIds: [toolId] })) as { SandboxToolSet?: Array<{ CustomConfiguration?: { Ports?: Array<{ Port?: number }>; Image?: string } }> @@ -72,13 +77,13 @@ async function main() { if (!tool?.CustomConfiguration) throw new Error('Tool not found') const cfg = tool.CustomConfiguration - const ports = mergePorts(cfg.Ports || []) + 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: ports, + Ports: STANDARD_TOOL_PORTS, }, } @@ -86,7 +91,8 @@ async function main() { try { const resp = await callAgs(action, param) console.log(`[update-stateful-tool-ports] ${action} ok`, JSON.stringify(resp).slice(0, 400)) - console.log('Ports now:', ports.map((p) => p.Port).join(', ')) + 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) diff --git a/packages/server/src/agent/cloudbase-agent.service.ts b/packages/server/src/agent/cloudbase-agent.service.ts index d0a6e84..3a188ab 100644 --- a/packages/server/src/agent/cloudbase-agent.service.ts +++ b/packages/server/src/agent/cloudbase-agent.service.ts @@ -27,6 +27,8 @@ import { import { decrypt } from '../lib/crypto.js' import { encryptJWE } from '../lib/session.js' import type { AgentCallbackMessage, AgentOptions, CodeBuddyMessage, ExtendedSessionUpdate } from '@coder/shared' +import { isSandboxToolName, sandboxLogMessageForTool } from '@coder/shared' +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' @@ -369,6 +371,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', @@ -769,6 +779,15 @@ export class CloudbaseAgentService { } } + 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: ToolOverrideConfig | null = null @@ -780,6 +799,7 @@ export class CloudbaseAgentService { // 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, @@ -787,6 +807,14 @@ export class CloudbaseAgentService { if (lastEmittedPhase && lastEmittedPhase.phase === phase && lastEmittedPhase.toolName === toolName) return lastEmittedPhase = { phase, toolName } wrappedCallback({ type: 'agent_phase', phase, phaseToolName: toolName }) + + 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') 桥接(勿先发无 toolName 的 preparing,否则会盖住复用/启动等细粒度文案) 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 f6605ac..b922d1e 100644 --- a/packages/server/src/agent/runtime/base-runtime.ts +++ b/packages/server/src/agent/runtime/base-runtime.ts @@ -16,6 +16,8 @@ */ 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 { buildStatefulAcquireContext } from '../../sandbox/acquire-context.js' @@ -399,9 +401,26 @@ export abstract class BaseAgentRuntime implements IAgentRuntime { // Non-critical } + 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 } } diff --git a/packages/server/src/routes/tasks.ts b/packages/server/src/routes/tasks.ts index f484814..e78e776 100644 --- a/packages/server/src/routes/tasks.ts +++ b/packages/server/src/routes/tasks.ts @@ -2504,6 +2504,36 @@ 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')! + const { taskId } = c.req.param() + const task = await findActiveTask(taskId, session.user.id) + if (!task) return c.json({ status: 'not_found' }) + if (!task.sandboxId) return c.json({ status: 'no_sandbox' }) + + const taskMode = (task as { mode?: string | null }).mode + const isCodingMode = taskMode === 'coding' + const sandbox = await getTaskSandbox(task, envId, { isCodingMode }) + if (!sandbox) return c.json({ status: 'no_sandbox' }) + + 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, + }) + } catch (error) { + return c.json({ status: 'error', message: (error as Error).message, retryable: true }) + } +}) + // --------------------------------------------------------------------------- // POST /:taskId/start-sandbox // --------------------------------------------------------------------------- diff --git a/packages/server/src/sandbox/__tests__/preview-proxy.test.ts b/packages/server/src/sandbox/__tests__/preview-proxy.test.ts index dcef8f2..7fec371 100644 --- a/packages/server/src/sandbox/__tests__/preview-proxy.test.ts +++ b/packages/server/src/sandbox/__tests__/preview-proxy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { rewritePreviewPaths } from '../preview-proxy.js' +import { rewritePreviewPaths, shouldRewritePreviewBody } from '../preview-proxy.js' describe('rewritePreviewPaths', () => { it('rewrites vite base asset URLs to task preview proxy', () => { @@ -9,4 +9,9 @@ describe('rewritePreviewPaths', () => { 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..056f0bf --- /dev/null +++ b/packages/server/src/sandbox/__tests__/preview-ws-proxy.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { buildGatewayWebSocketUrl, 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('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/ensure-stateful-tool.ts b/packages/server/src/sandbox/ensure-stateful-tool.ts index 46bb4f6..af11bed 100644 --- a/packages/server/src/sandbox/ensure-stateful-tool.ts +++ b/packages/server/src/sandbox/ensure-stateful-tool.ts @@ -115,11 +115,11 @@ async function createSandboxTool(envId: string): Promise<string> { 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 }, - { Name: 'vite', Protocol: 'TCP', Port: 5173 }, - { Name: 'ttyd', Protocol: 'TCP', Port: 7681 }, ], Probe: { HttpGet: { Path: '/health', Port: 9000, Scheme: 'HTTP' }, diff --git a/packages/server/src/sandbox/preview-proxy.ts b/packages/server/src/sandbox/preview-proxy.ts index 4baa598..84f2c96 100644 --- a/packages/server/src/sandbox/preview-proxy.ts +++ b/packages/server/src/sandbox/preview-proxy.ts @@ -4,6 +4,8 @@ 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', @@ -48,7 +50,9 @@ export function rewritePreviewPaths(body: string, taskId: string, port: string): return body.split(from).join(to) } -function shouldRewriteBody(contentType: string | null): boolean { +/** 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 ( @@ -83,7 +87,10 @@ export async function proxyTaskPreview( subpath: string, ): Promise<Response> { const authHeaders = await sandbox.getAuthHeaders() - const upstreamPath = buildUpstreamPreviewPath(port, subpath) + 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}` @@ -119,7 +126,7 @@ export async function proxyTaskPreview( }) const contentType = upstream.headers.get('content-type') - if (shouldRewriteBody(contentType)) { + if (shouldRewritePreviewBody(contentType, port)) { const raw = await upstream.text() const rewritten = rewritePreviewPaths(raw, taskId, port) return new Response(rewritten, { diff --git a/packages/server/src/sandbox/preview-ws-proxy.ts b/packages/server/src/sandbox/preview-ws-proxy.ts index a0536a8..9a7b72b 100644 --- a/packages/server/src/sandbox/preview-ws-proxy.ts +++ b/packages/server/src/sandbox/preview-ws-proxy.ts @@ -1,100 +1,210 @@ /** - * WebSocket upgrade proxy: browser → OpenVibeCoding → 沙箱业务镜像 /preview/{port}/ (vite HMR). + * WebSocket upgrade proxy: browser → OpenVibeCoding → TRW /preview/{port}/ (vite HMR, ttyd). */ import type { IncomingMessage, Server } from 'node:http' -import type { Duplex } from 'node:stream' -import http from 'node:http' -import https from 'node:https' 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 HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailers', + 'transfer-encoding', + 'upgrade', + 'host', + 'cookie', + 'content-length', +]) + +/** Client→OVC handshake headers must not be forwarded; upstream `ws` generates its own. */ +const CLIENT_WS_HANDSHAKE = new Set([ + 'sec-websocket-key', + 'sec-websocket-version', + 'sec-websocket-extensions', + 'sec-websocket-protocol', +]) + +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}` } -function pipeSockets(a: Duplex, b: Duplex): void { - a.pipe(b) - b.pipe(a) - const onClose = () => { - a.destroy() - b.destroy() +/** 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}` +} + +function buildForwardHeaders(req: IncomingMessage, authHeaders: Record<string, string>): Record<string, string> { + const out: Record<string, string> = { ...authHeaders } + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) continue + const lower = key.toLowerCase() + if (HOP_BY_HOP.has(lower) || CLIENT_WS_HANDSHAKE.has(lower)) continue + out[key] = Array.isArray(value) ? value.join(', ') : value + } + return out +} + +/** 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<string, string>, +): 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) } - a.on('close', onClose) - b.on('close', onClose) - a.on('error', onClose) - b.on('error', onClose) +} + +/** 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.on('upgrade', (req, clientSocket, _head) => { - const url = req.url ?? '/' - const match = PREVIEW_WS_RE.exec(url.split('?')[0] ?? url) + 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 (clientSocket.writable) clientSocket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') - clientSocket.destroy() + if (socket.writable) socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') + socket.destroy() return } - const upstreamPath = buildUpstreamPath(port, subpath) - const base = new URL(sandbox.baseUrl) - const query = url.includes('?') ? url.slice(url.indexOf('?')) : '' - const targetPath = `${upstreamPath}${query}` + 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 forwardHeaders = buildForwardHeaders(req, await sandbox.getAuthHeaders()) + const clientProtocols = parseClientWsProtocols(req) - const authHeaders = await sandbox.getAuthHeaders() - const forwardHeaders: Record<string, string | string[] | undefined> = { ...req.headers } - delete forwardHeaders.host - for (const [k, v] of Object.entries(authHeaders)) { - forwardHeaders[k] = v - } + wss.handleUpgrade(req, socket, head, (clientWs) => { + const upstreamWs = connectUpstreamPreviewWebSocket(wsUrl, clientProtocols, forwardHeaders) + bridgeSockets(clientWs, upstreamWs) - const requestFn = base.protocol === 'https:' ? https.request : http.request - const proxyReq = requestFn({ - hostname: base.hostname, - port: base.port || (base.protocol === 'https:' ? 443 : 80), - path: targetPath, - method: req.method, - headers: forwardHeaders, - }) + 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) + }) - proxyReq.on('upgrade', (res, upstreamSocket, upgradeHead) => { - const statusLine = `HTTP/1.1 ${res.statusCode ?? 101} ${res.statusMessage ?? 'Switching Protocols'}` - const headerLines = Object.entries(res.headers) - .flatMap(([key, values]) => (Array.isArray(values) ? values : [values]).map((v) => `${key}: ${v}`)) - .join('\r\n') - clientSocket.write(`${statusLine}\r\n${headerLines}\r\n\r\n`) - if (upgradeHead.length > 0) upstreamSocket.write(upgradeHead) - pipeSockets(clientSocket, upstreamSocket) - }) + upstreamWs.on('unexpected-response', (_proxyReq, res) => { + console.warn('[preview-ws-proxy] upstream rejected WebSocket upgrade') + 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() + }) - proxyReq.on('error', (err) => { - console.warn('[preview-ws-proxy] upstream error:', (err as Error).message) - if (clientSocket.writable) clientSocket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n') - clientSocket.destroy() - }) + upstreamWs.on('error', () => { + console.warn('[preview-ws-proxy] upstream WebSocket error') + clientWs.close() + }) - proxyReq.on('response', (res) => { - if (res.statusCode && res.statusCode >= 400) { - clientSocket.write(`HTTP/1.1 ${res.statusCode} ${res.statusMessage ?? 'Error'}\r\n\r\n`) - clientSocket.destroy() - } + clientWs.on('error', () => { + upstreamWs.terminate() + }) }) - - proxyReq.end() })().catch((err) => { console.warn('[preview-ws-proxy] handler error:', (err as Error).message) - clientSocket.destroy() + socket.destroy() }) }) } diff --git a/packages/server/src/sandbox/stateful-vibecoding-image.ts b/packages/server/src/sandbox/stateful-vibecoding-image.ts index 5bfea90..1b9bdf5 100644 --- a/packages/server/src/sandbox/stateful-vibecoding-image.ts +++ b/packages/server/src/sandbox/stateful-vibecoding-image.ts @@ -18,7 +18,7 @@ export const VIBECODING_PUBLIC_TCR_REPO = /** Default tag when URI has no `:tag` (一条龙格式 YYMMDD-HHMM-…-vibecoding). */ export const VIBECODING_PUBLIC_TCR_DEFAULT_TAG = - process.env.STATEFUL_SANDBOX_IMAGE_TAG?.trim() || '260521-1705-vibecoding' + 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' 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<PreviewPortRow[]> { + 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<number | null> { + 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<number> { + 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<TtydPreviewProbe> { + 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<void> { + 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<TtydPreviewResolve> { + 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/shared/src/index.ts b/packages/shared/src/index.ts index d222122..7b04266 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,3 +4,4 @@ export * from './types/task' export * from './types/user' export * from './types/acp' export * from './types/config' +export * from './sandbox-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<string, string> = { + '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/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/logs-pane.tsx b/packages/web/src/components/logs-pane.tsx index 59b879a..958dbec 100644 --- a/packages/web/src/components/logs-pane.tsx +++ b/packages/web/src/components/logs-pane.tsx @@ -3,8 +3,10 @@ import { Button } from '@/components/ui/button' import { Copy, Check, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' import { cn } from '@/lib/utils' import { useState, useEffect, useRef } from 'react' +import { useSetAtom } from 'jotai' import { toast } from 'sonner' import { useTasks } from '@/components/app-layout' +import { streamLogsAtomFamily } from '@/lib/atoms/stream-logs' import { getLogsPaneHeight, setLogsPaneHeight, getLogsPaneCollapsed, setLogsPaneCollapsed } from '@/lib/utils/cookies' import { Terminal, TerminalRef } from '@/components/terminal' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' @@ -17,9 +19,13 @@ interface LogsPaneProps { type TabType = 'logs' | 'terminal' type LogFilterType = 'all' | 'platform' | 'server' +/** ttyd/xterm needs a real pixel height; smaller panes render a blank black iframe */ +const TERMINAL_PANE_MIN_HEIGHT = 200 +const PANE_HEIGHT_MIN = 100 +const PANE_HEIGHT_MAX_CAP = 600 + export function LogsPane({ task, onHeightChange }: LogsPaneProps) { const [copiedLogs, setCopiedLogs] = useState(false) - const [copiedTerminal, setCopiedTerminal] = useState(false) const [isCollapsed, setIsCollapsedState] = useState(true) const [paneHeight, setPaneHeight] = useState(200) const [isResizing, setIsResizing] = useState(false) @@ -34,6 +40,7 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { const hasInitialScrolled = useRef<boolean>(false) const wasAtBottomRef = useRef<boolean>(true) const { isSidebarOpen, isSidebarResizing, refreshTasks } = useTasks() + const setStreamLogs = useSetAtom(streamLogsAtomFamily(task.id)) // Check if we're on desktop useEffect(() => { @@ -77,36 +84,36 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { } }, [paneHeight, isCollapsed, onHeightChange]) - // Handle resize + const paneHeightMax = () => Math.min(PANE_HEIGHT_MAX_CAP, Math.floor(window.innerHeight * 0.75)) + + // Drag top edge to resize (pointer capture — do not tie to header click-to-collapse) useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { - if (!isResizing) return + if (!isResizing) return - // Calculate new height (resize from top, so subtract from window height) + const handlePointerMove = (e: PointerEvent) => { + const maxHeight = paneHeightMax() const newHeight = window.innerHeight - e.clientY - const minHeight = 100 - const maxHeight = 600 - - if (newHeight >= minHeight && newHeight <= maxHeight) { + if (newHeight >= PANE_HEIGHT_MIN && newHeight <= maxHeight) { setPaneHeight(newHeight) setLogsPaneHeight(newHeight) } } - const handleMouseUp = () => { + const endResize = () => { setIsResizing(false) + window.dispatchEvent(new Event('resize')) } - if (isResizing) { - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleMouseUp) - document.body.style.cursor = 'row-resize' - document.body.style.userSelect = 'none' - } + document.addEventListener('pointermove', handlePointerMove) + document.addEventListener('pointerup', endResize) + document.addEventListener('pointercancel', endResize) + document.body.style.cursor = 'row-resize' + document.body.style.userSelect = 'none' return () => { - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('mouseup', handleMouseUp) + document.removeEventListener('pointermove', handlePointerMove) + document.removeEventListener('pointerup', endResize) + document.removeEventListener('pointercancel', endResize) document.body.style.cursor = '' document.body.style.userSelect = '' } @@ -188,6 +195,7 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { }) if (response.ok) { + setStreamLogs([]) refreshTasks() } else { const error = await response.json() @@ -207,152 +215,154 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { } } - const copyTerminalToClipboard = async () => { - if (terminalRef.current) { - try { - const terminalText = terminalRef.current.getTerminalText() - await navigator.clipboard.writeText(terminalText) - setCopiedTerminal(true) - setTimeout(() => setCopiedTerminal(false), 2000) - } catch { - toast.error('Failed to copy terminal to clipboard') - } + const openTerminalTab = () => { + if (isCollapsed) { + setIsCollapsed(false) + } + if (paneHeight < TERMINAL_PANE_MIN_HEIGHT) { + setPaneHeight(TERMINAL_PANE_MIN_HEIGHT) + setLogsPaneHeight(TERMINAL_PANE_MIN_HEIGHT) + onHeightChange?.(TERMINAL_PANE_MIN_HEIGHT) } + setActiveTab('terminal') } - const handleMouseDown = (e: React.MouseEvent) => { + const handleResizePointerDown = (e: React.PointerEvent<HTMLDivElement>) => { e.preventDefault() + e.stopPropagation() + e.currentTarget.setPointerCapture(e.pointerId) setIsResizing(true) } return ( <div - className={`fixed bottom-0 right-0 z-10 bg-background ${isResizing || isSidebarResizing || !hasMounted ? '' : 'transition-all duration-300 ease-in-out'}`} + className={`fixed bottom-0 right-0 z-10 bg-background ${isResizing || isSidebarResizing || !hasMounted ? '' : 'transition-[left] duration-300 ease-in-out'}`} style={{ left: isDesktop && isSidebarOpen ? 'var(--sidebar-width)' : '0px', height: isCollapsed ? 'auto' : `${paneHeight}px`, }} > - {/* Resize Handle */} - {!isCollapsed && ( - <div - className={`absolute top-0 left-0 right-0 h-1 cursor-row-resize group hover:bg-primary/20 ${isResizing ? '' : 'transition-colors'}`} - onMouseDown={handleMouseDown} - > - <div className="absolute inset-x-0 top-0 h-2 -mt-0.5" /> - <div className="absolute inset-x-0 top-0 h-0.5 bg-primary/50 opacity-0 group-hover:opacity-100 transition-opacity" /> - </div> - )} - - <div className="flex flex-col h-full border-t"> - <div - className="border-b flex items-center justify-between flex-shrink-0 hover:bg-accent/50 cursor-pointer" - onClick={() => setIsCollapsed(!isCollapsed)} - > - <div className="flex items-center gap-1.5 py-1.5 px-3 flex-1"> - <div className="h-5 w-5 flex items-center justify-center"> - {isCollapsed ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />} - </div> - <div className="flex items-center gap-2"> - <button - onClick={(e) => { - e.stopPropagation() - if (isCollapsed) { - setIsCollapsed(false) - } - setActiveTab('logs') - }} - className={cn( - 'text-xs font-medium uppercase tracking-wide transition-colors px-2 py-1 rounded', - activeTab === 'logs' - ? 'text-foreground bg-accent' - : 'text-muted-foreground hover:text-foreground hover:bg-accent/50', - )} - > - Logs - </button> + <div className={cn('flex flex-col border-t', isCollapsed ? '' : 'h-full min-h-0')}> + {!isCollapsed && ( + <div + role="separator" + aria-orientation="horizontal" + aria-label="Resize logs pane" + className={cn( + 'flex h-2 shrink-0 cursor-row-resize touch-none items-center justify-center border-b', + isResizing ? 'bg-primary/20' : 'bg-muted/40 hover:bg-muted/60', + )} + onPointerDown={handleResizePointerDown} + > + <div className="h-1 w-10 rounded-full bg-border" /> + </div> + )} + <div className="relative flex flex-shrink-0 items-center justify-between border-b"> + <div className="flex min-w-0 flex-1 items-center justify-between"> + <div className="flex flex-1 items-center gap-1.5 px-3 py-1.5"> <button - onClick={(e) => { - e.stopPropagation() - if (isCollapsed) { - setIsCollapsed(false) - } - setActiveTab('terminal') - }} - className={cn( - 'text-xs font-medium uppercase tracking-wide transition-colors px-2 py-1 rounded', - activeTab === 'terminal' - ? 'text-foreground bg-accent' - : 'text-muted-foreground hover:text-foreground hover:bg-accent/50', - )} + type="button" + className="flex h-5 w-5 items-center justify-center rounded hover:bg-accent" + aria-label={isCollapsed ? 'Expand logs pane' : 'Collapse logs pane'} + onClick={() => setIsCollapsed(!isCollapsed)} > - Terminal + {isCollapsed ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />} </button> + <div className="flex items-center gap-2"> + <button + onClick={(e) => { + e.stopPropagation() + if (isCollapsed) { + setIsCollapsed(false) + } + setActiveTab('logs') + }} + className={cn( + 'text-xs font-medium uppercase tracking-wide transition-colors px-2 py-1 rounded', + activeTab === 'logs' + ? 'text-foreground bg-accent' + : 'text-muted-foreground hover:text-foreground hover:bg-accent/50', + )} + > + Logs + </button> + <button + onClick={(e) => { + e.stopPropagation() + openTerminalTab() + }} + className={cn( + 'text-xs font-medium uppercase tracking-wide transition-colors px-2 py-1 rounded', + activeTab === 'terminal' + ? 'text-foreground bg-accent' + : 'text-muted-foreground hover:text-foreground hover:bg-accent/50', + )} + > + Terminal + </button> + </div> </div> + {activeTab === 'logs' && ( + <div className="flex items-center gap-1.5 mr-3" onClick={(e) => e.stopPropagation()}> + <Select value={logFilter} onValueChange={(value) => setLogFilter(value as LogFilterType)}> + <SelectTrigger size="sm" className="h-6 text-xs px-2 py-0 min-w-[90px] border-0 shadow-none"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">All</SelectItem> + <SelectItem value="platform">Platform</SelectItem> + <SelectItem value="server">Server</SelectItem> + </SelectContent> + </Select> + <Button + variant="ghost" + size="sm" + onClick={clearLogs} + disabled={isClearingLogs} + className="h-5 w-5 p-0 hover:bg-accent" + title="Clear logs" + > + <Trash2 className="h-3 w-3" /> + </Button> + <Button + variant="ghost" + size="sm" + onClick={copyLogsToClipboard} + className="h-5 w-5 p-0 hover:bg-accent" + title="Copy logs to clipboard" + > + {copiedLogs ? <Check className="h-3 w-3 text-green-600" /> : <Copy className="h-3 w-3" />} + </Button> + </div> + )} + {activeTab === 'terminal' && ( + <div className="flex items-center gap-1 mr-3" onClick={(e) => e.stopPropagation()}> + <Button + variant="ghost" + size="sm" + onClick={clearTerminal} + className="h-5 w-5 p-0 hover:bg-accent" + title="重新加载终端" + > + <Trash2 className="h-3 w-3" /> + </Button> + </div> + )} </div> - {activeTab === 'logs' && ( - <div className="flex items-center gap-1.5 mr-3" onClick={(e) => e.stopPropagation()}> - <Select value={logFilter} onValueChange={(value) => setLogFilter(value as LogFilterType)}> - <SelectTrigger size="sm" className="h-6 text-xs px-2 py-0 min-w-[90px] border-0 shadow-none"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="all">All</SelectItem> - <SelectItem value="platform">Platform</SelectItem> - <SelectItem value="server">Server</SelectItem> - </SelectContent> - </Select> - <Button - variant="ghost" - size="sm" - onClick={clearLogs} - disabled={isClearingLogs} - className="h-5 w-5 p-0 hover:bg-accent" - title="Clear logs" - > - <Trash2 className="h-3 w-3" /> - </Button> - <Button - variant="ghost" - size="sm" - onClick={copyLogsToClipboard} - className="h-5 w-5 p-0 hover:bg-accent" - title="Copy logs to clipboard" - > - {copiedLogs ? <Check className="h-3 w-3 text-green-600" /> : <Copy className="h-3 w-3" />} - </Button> - </div> - )} - {activeTab === 'terminal' && ( - <div className="flex items-center gap-1 mr-3" onClick={(e) => e.stopPropagation()}> - <Button - variant="ghost" - size="sm" - onClick={clearTerminal} - className="h-5 w-5 p-0 hover:bg-accent" - title="Clear terminal" - > - <Trash2 className="h-3 w-3" /> - </Button> - <Button - variant="ghost" - size="sm" - onClick={copyTerminalToClipboard} - className="h-5 w-5 p-0 hover:bg-accent" - title="Copy terminal to clipboard" - > - {copiedTerminal ? <Check className="h-3 w-3 text-green-600" /> : <Copy className="h-3 w-3" />} - </Button> - </div> - )} </div> <div ref={logsContainerRef} className={cn( 'bg-black text-green-400 p-2 font-mono text-xs flex-1 overflow-y-auto leading-relaxed', (isCollapsed || activeTab !== 'logs') && 'hidden', + isResizing && 'pointer-events-none select-none', )} > + {getFilteredLogs(logFilter).length === 0 ? ( + <div className="text-muted-foreground/70 italic px-1 py-2"> + 暂无日志。Agent 启动沙箱或执行任务后,平台进度会显示在这里。 + </div> + ) : null} {getFilteredLogs(logFilter).map((log, index) => { const isServerLog = log.message.startsWith('[SERVER]') const messageContent = isServerLog ? log.message.substring(9) : log.message // Remove '[SERVER] ' @@ -393,7 +403,13 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { ) })} </div> - <div className={cn('flex-1 overflow-hidden', (isCollapsed || activeTab !== 'terminal') && 'hidden')}> + <div + className={cn( + 'relative flex-1 min-h-[12rem] overflow-hidden bg-black', + (isCollapsed || activeTab !== 'terminal') && 'hidden', + isResizing && 'pointer-events-none select-none', + )} + > <Terminal ref={terminalRef} taskId={task.id} diff --git a/packages/web/src/components/task-page-client.tsx b/packages/web/src/components/task-page-client.tsx index 0abd7a0..07e03e6 100644 --- a/packages/web/src/components/task-page-client.tsx +++ b/packages/web/src/components/task-page-client.tsx @@ -1,6 +1,9 @@ import { useState, useMemo, useRef, useEffect, useCallback } from 'react' +import { useAtom } from 'jotai' import { useSearchParams } from 'react-router' +import type { LogEntry, Task } from '@coder/shared' import { useTask } from '@/hooks/use-task' +import { streamLogsAtomFamily } from '@/lib/atoms/stream-logs' import { TaskDetails } from '@/components/task-details' import { SharedHeader } from '@/components/shared-header' import { TaskActions } from '@/components/task-actions' @@ -14,6 +17,14 @@ interface TaskPageClientProps { maxSandboxDuration?: number } +function mergeTaskLogs(persisted: Task['logs'], live: LogEntry[]): Task['logs'] { + const base = persisted ?? [] + if (!live.length) return base + const seen = new Set(base.map((e) => `${e.type}:${e.message}`)) + const extra = live.filter((e) => !seen.has(`${e.type}:${e.message}`)) + return extra.length ? [...base, ...extra] : base +} + function parseRepoFromUrl(repoUrl: string | null): { owner: string; repo: string } | null { if (!repoUrl) return null try { @@ -38,8 +49,16 @@ export function TaskPageClient({ maxSandboxDuration = 300, }: TaskPageClientProps) { const { task, isLoading, error, refetch } = useTask(taskId) + const [streamLogs] = useAtom(streamLogsAtomFamily(taskId)) const [logsPaneHeight, setLogsPaneHeight] = useState(40) + const taskForLogsPane = useMemo(() => { + if (!task) return task + const logs = mergeTaskLogs(task.logs, streamLogs) + if (logs === task.logs) return task + return { ...task, logs } + }, [task, streamLogs]) + // 读取 URL ?prompt= 参数,只读一次然后清除 const [searchParams, setSearchParams] = useSearchParams() const [initialPrompt, setInitialPrompt] = useState<string | undefined>(undefined) @@ -127,7 +146,7 @@ export function TaskPageClient({ /> </div> - <LogsPane task={task} onHeightChange={setLogsPaneHeight} /> + <LogsPane task={taskForLogsPane ?? task} onHeightChange={setLogsPaneHeight} /> </div> ) } diff --git a/packages/web/src/components/terminal.tsx b/packages/web/src/components/terminal.tsx index e3bd262..712227c 100644 --- a/packages/web/src/components/terminal.tsx +++ b/packages/web/src/components/terminal.tsx @@ -1,8 +1,13 @@ /** - * Web terminal via 沙箱业务镜像 ttyd — virtual port 7681, proxied as /api/tasks/:id/preview/7681/. + * Web terminal via TRW ttyd — virtual port 7681 only, proxied as /api/tasks/:id/preview/7681/. */ -import { forwardRef, useImperativeHandle } from 'react' +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' +import { Loader2, RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/button' + +/** Must match packages/server/src/sandbox/ttyd-preview.ts TTYD_VIRTUAL_PORT */ +const TTYD_PREVIEW_PORT = 7681 interface TerminalProps { taskId: string @@ -17,37 +22,190 @@ export interface TerminalRef { getTerminalText: () => string } +type TerminalGateStatus = 'idle' | 'no_sandbox' | 'checking' | 'ready' | 'starting' | 'unavailable' | 'error' + +type TerminalHealthPayload = { + status?: string + retryable?: boolean +} + +const POLL_MS = 2000 +const MAX_POLLS = 30 + +function notifyTtydResize(iframe: HTMLIFrameElement | null) { + if (!iframe?.contentWindow) return + try { + iframe.contentWindow.dispatchEvent(new Event('resize')) + } catch { + // ignore cross-origin (should be same-origin) + } +} + export const Terminal = forwardRef<TerminalRef, TerminalProps>(function Terminal( { taskId, className, isActive, sandboxReady = true }, ref, ) { + const [gateStatus, setGateStatus] = useState<TerminalGateStatus>('idle') + const [iframeEpoch, setIframeEpoch] = useState(0) + const pollRef = useRef(0) + const probeGenerationRef = useRef(0) + const iframeRef = useRef<HTMLIFrameElement>(null) + + const iframeSrc = `/api/tasks/${taskId}/preview/${TTYD_PREVIEW_PORT}/` + useImperativeHandle(ref, () => ({ - clear: () => {}, + clear: () => { + probeGenerationRef.current += 1 + setIframeEpoch((n) => n + 1) + if (isActive && sandboxReady) void runProbeLoop() + }, getTerminalText: () => '', })) - if (!sandboxReady) { + const checkTerminal = useCallback(async (): Promise<TerminalGateStatus> => { + if (!sandboxReady) return 'no_sandbox' + try { + const res = await fetch(`/api/tasks/${taskId}/terminal-health`, { credentials: 'include' }) + const data = (await res.json()) as TerminalHealthPayload + switch (data.status) { + case 'ready': + return 'ready' + case 'starting': + return 'starting' + case 'no_sandbox': + return 'no_sandbox' + case 'not_found': + case 'unavailable': + return data.retryable ? 'starting' : 'unavailable' + case 'error': + return data.retryable ? 'starting' : 'error' + default: + return data.retryable ? 'starting' : 'unavailable' + } + } catch { + return 'starting' + } + }, [sandboxReady, taskId]) + + const runProbeLoop = useCallback(async () => { + const generation = ++probeGenerationRef.current + pollRef.current = 0 + setGateStatus(sandboxReady ? 'checking' : 'no_sandbox') + if (!sandboxReady) return + + while (pollRef.current < MAX_POLLS) { + if (generation !== probeGenerationRef.current) return + + const status = await checkTerminal() + if (generation !== probeGenerationRef.current) return + + if (status === 'ready') { + setGateStatus('ready') + return + } + if (status === 'no_sandbox') { + setGateStatus('no_sandbox') + return + } + if (status === 'unavailable' || status === 'error') { + setGateStatus(status) + return + } + + setGateStatus('starting') + pollRef.current += 1 + await new Promise((r) => setTimeout(r, POLL_MS)) + } + if (generation === probeGenerationRef.current) { + setGateStatus('unavailable') + } + }, [checkTerminal, sandboxReady]) + + useEffect(() => { + if (!isActive) { + probeGenerationRef.current += 1 + setGateStatus('idle') + return + } + void runProbeLoop() + }, [isActive, runProbeLoop, taskId, sandboxReady]) + + useEffect(() => { + probeGenerationRef.current += 1 + setIframeEpoch((n) => n + 1) + }, [taskId]) + + useEffect(() => { + if (gateStatus !== 'ready' || !isActive) return + const onResize = () => notifyTtydResize(iframeRef.current) + window.addEventListener('resize', onResize) + const t = window.setTimeout(onResize, 100) + return () => { + window.removeEventListener('resize', onResize) + window.clearTimeout(t) + } + }, [gateStatus, isActive, iframeEpoch]) + + const handleRetry = () => { + setIframeEpoch((n) => n + 1) + probeGenerationRef.current += 1 + void runProbeLoop() + } + + if (!isActive) { + return null + } + + if (gateStatus === 'no_sandbox') { return ( <div - className={`h-full bg-black text-muted-foreground p-2 font-mono text-xs flex items-center justify-center ${className ?? ''}`} + className={`h-full min-h-0 bg-black text-muted-foreground p-4 font-mono text-xs flex flex-col items-center justify-center gap-2 text-center ${className ?? ''}`} > - 沙箱未就绪,终端暂不可用 + <p>沙箱未就绪,终端暂不可用</p> + <p className="text-[10px] opacity-70">请先发送一条消息,待沙箱启动后再打开 Terminal</p> </div> ) } - if (!isActive) { - return <div className={`h-full min-h-0 bg-black ${className ?? ''}`} /> + if (gateStatus === 'checking' || gateStatus === 'starting' || gateStatus === 'idle') { + return ( + <div + className={`h-full min-h-0 bg-black text-muted-foreground p-4 font-mono text-xs flex flex-col items-center justify-center gap-2 ${className ?? ''}`} + > + <Loader2 className="h-4 w-4 animate-spin" /> + <p>{gateStatus === 'checking' ? '正在检查 Web 终端…' : '正在启动 Web 终端(ttyd)…'}</p> + </div> + ) } - const src = `/api/tasks/${taskId}/preview/7681/` + if (gateStatus === 'unavailable' || gateStatus === 'error') { + return ( + <div + className={`h-full min-h-0 bg-black text-muted-foreground p-4 font-mono text-xs flex flex-col items-center justify-center gap-3 text-center ${className ?? ''}`} + > + <p>{gateStatus === 'error' ? '无法连接沙箱终端' : 'Web 终端暂不可用'}</p> + <p className="text-[10px] opacity-70 max-w-md"> + 终端走沙箱虚拟口 7681。若长时间不可用,点「重试」或先发一条消息等待沙箱就绪。 + </p> + <Button type="button" variant="outline" size="sm" className="h-7 text-xs" onClick={handleRetry}> + <RefreshCw className="h-3 w-3 mr-1" /> + 重试 + </Button> + </div> + ) + } return ( - <iframe - src={src} - title="Web Terminal" - className={`h-full min-h-0 w-full border-0 bg-black ${className ?? ''}`} - allow="clipboard-read; clipboard-write" - /> + <div className={`absolute inset-0 bg-black ${className ?? ''}`}> + <iframe + ref={iframeRef} + key={`${taskId}-${iframeEpoch}`} + src={iframeSrc} + title="Web Terminal" + className="size-full border-0 bg-black" + allow="clipboard-read; clipboard-write" + onLoad={() => notifyTtydResize(iframeRef.current)} + /> + </div> ) }) diff --git a/packages/web/src/hooks/apply-session-update.ts b/packages/web/src/hooks/apply-session-update.ts index b6b42fe..40a354d 100644 --- a/packages/web/src/hooks/apply-session-update.ts +++ b/packages/web/src/hooks/apply-session-update.ts @@ -13,7 +13,7 @@ * - clearQuestionState 在 tool_call_update 里调用,hook 内部已 useCallback */ import type { Dispatch, SetStateAction, MutableRefObject } from 'react' -import type { ExtendedSessionUpdate, AgentPhaseName } from '@coder/shared' +import type { ExtendedSessionUpdate, AgentPhaseName, LogEntry } from '@coder/shared' import type { TaskMessage, ToolConfirmData, DeploymentInfo, ArtifactInfo } from '@/types/task-chat' import { extractPlanContent } from '@/components/chat/plan-content' import { @@ -128,6 +128,7 @@ export interface ApplySessionUpdateCtx { */ setIsSending: Dispatch<SetStateAction<boolean>> setIsStreamingResponse: Dispatch<SetStateAction<boolean>> + appendStreamLog?: (entry: LogEntry) => void } /** @@ -159,10 +160,22 @@ export function applySessionUpdate(ctx: ApplySessionUpdateCtx): void { clearQuestionState, setIsSending, setIsStreamingResponse, + appendStreamLog, } = ctx const u = update as any switch (update.sessionUpdate) { + case 'log': { + const level = u.level === 'error' || u.level === 'success' || u.level === 'command' ? u.level : 'info' + const message = typeof u.message === 'string' ? u.message : '' + if (!message || !appendStreamLog) break + appendStreamLog({ + type: level, + message, + timestamp: typeof u.timestamp === 'number' ? u.timestamp : Date.now(), + }) + break + } case 'agent_message_chunk': { setMessages((prev) => prev.map((m) => { diff --git a/packages/web/src/hooks/use-chat-stream.ts b/packages/web/src/hooks/use-chat-stream.ts index 67829af..2bf31d3 100644 --- a/packages/web/src/hooks/use-chat-stream.ts +++ b/packages/web/src/hooks/use-chat-stream.ts @@ -16,6 +16,8 @@ import { toast } from 'sonner' import type { ExtendedSessionUpdate, PermissionAction, AgentPermissionMode } from '@coder/shared' import type { TaskMessage, AskUserQuestionData, ToolConfirmData, DeploymentInfo, ArtifactInfo } from '@/types/task-chat' import { planModeAtomFamily } from '@/lib/atoms/plan-mode' +import { streamLogsAtomFamily } from '@/lib/atoms/stream-logs' +import type { LogEntry } from '@coder/shared' import { AcpClient } from '@/lib/acp' import { applySessionUpdate, IDLE_AGENT_PHASE, type AgentPhaseInfo } from './apply-session-update' @@ -76,6 +78,7 @@ export function useChatStream(taskId: string, options: UseChatStreamOptions = {} // · `planMode.active` 决定下一轮 prompt 的 permissionMode // · planContent + toolCallId 用于回显审批卡片 const [planMode, setPlanMode] = useAtom(planModeAtomFamily(taskId)) + const [streamLogs, setStreamLogs] = useAtom(streamLogsAtomFamily(taskId)) // ── Agent phase state (P4) ── // · 服务端在关键边界推送 `sessionUpdate: 'agent_phase'` 事件 @@ -107,10 +110,11 @@ export function useChatStream(taskId: string, options: UseChatStreamOptions = {} setManualInputsByTool({}) setDeploymentNotifications([]) setArtifacts([]) + setStreamLogs([]) phaseRef.current = 'idle' setIsSending(false) setIsStreamingResponse(false) - }, [taskId]) + }, [setStreamLogs, taskId]) // ════════════════════════════════════════════════════════════════════ // AskUser / ToolConfirm cleanup helpers @@ -141,7 +145,8 @@ export function useChatStream(taskId: string, options: UseChatStreamOptions = {} setIsSending(true) setIsStreamingResponse(true) setAgentPhase(IDLE_PHASE) - }, []) + setStreamLogs([]) + }, [setStreamLogs]) const exitStreaming = useCallback(async () => { const wasWaiting = phaseRef.current === 'waiting_for_interaction' @@ -154,14 +159,29 @@ export function useChatStream(taskId: string, options: UseChatStreamOptions = {} // 避免上一轮的 "执行 Bash" 之类的指示器残留到下一轮启动前。 setAgentPhase(IDLE_PHASE) if (!wasWaiting) { - optionsRef.current.onStreamComplete?.() + const onDone = optionsRef.current.onStreamComplete?.() + if (onDone && typeof (onDone as Promise<unknown>).then === 'function') { + await onDone + } } - }, []) + setStreamLogs([]) + }, [setStreamLogs]) // ════════════════════════════════════════════════════════════════════ // SSE stream processing // ════════════════════════════════════════════════════════════════════ + const appendStreamLog = useCallback( + (entry: LogEntry) => { + setStreamLogs((prev) => { + const last = prev[prev.length - 1] + if (last && last.message === entry.message && last.type === entry.type) return prev + return [...prev, entry] + }) + }, + [setStreamLogs], + ) + /** Dispatch a single SSE sessionUpdate event to the appropriate state setter. */ const applyStreamUpdate = useCallback( (update: ExtendedSessionUpdate, assistantMsgId: string) => { @@ -180,9 +200,10 @@ export function useChatStream(taskId: string, options: UseChatStreamOptions = {} clearQuestionState, setIsSending, setIsStreamingResponse, + appendStreamLog, }) }, - [clearQuestionState, setPlanMode, taskId], + [appendStreamLog, clearQuestionState, setPlanMode, taskId], ) // ════════════════════════════════════════════════════════════════════ @@ -535,6 +556,9 @@ export function useChatStream(taskId: string, options: UseChatStreamOptions = {} agentPhase, setAgentPhase, + // Live platform logs (SSE log → LogsPane) + streamLogs, + // Phase (for fetchMessages guard) canFetchMessages, phaseRef, diff --git a/packages/web/src/lib/atoms/stream-logs.ts b/packages/web/src/lib/atoms/stream-logs.ts new file mode 100644 index 0000000..893c7f5 --- /dev/null +++ b/packages/web/src/lib/atoms/stream-logs.ts @@ -0,0 +1,6 @@ +import { atom } from 'jotai' +import { atomFamily } from 'jotai/utils' +import type { LogEntry } from '@coder/shared' + +/** Live log lines from SSE `sessionUpdate: log` during an agent turn (merged into LogsPane). */ +export const streamLogsAtomFamily = atomFamily((_taskId: string) => atom<LogEntry[]>([])) diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 1cfa8f5..ba96dd4 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -29,9 +29,15 @@ export default defineConfig({ '/api': { target: 'http://localhost:3001', changeOrigin: true, + ws: true, // SSE 流式响应需要禁用超时,否则 Vite proxy 会缓冲数据 timeout: 0, proxyTimeout: 0, + configure: (proxy) => { + proxy.on('error', (err) => { + console.error('[vite] /api proxy error:', err.message) + }) + }, }, }, }, From d762a4a18a477f567927198bc6ea2346aafaa066 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Tue, 26 May 2026 18:48:38 +0800 Subject: [PATCH 19/29] fix(tasks): fast delete-all without blocking on sandbox archive Batch soft-delete with configurable concurrency; shared mode stops one sandbox instance; isolated destroys run in background. Optimistic sidebar clear with revert on failure. Restore probe-gateway-port ops script. Co-authored-by: Cursor <cursoragent@cursor.com> --- packages/server/scripts/probe-gateway-port.ts | 86 ++++++++----- packages/server/src/routes/tasks.ts | 119 +++++++++++------- packages/web/src/components/app-layout.tsx | 21 +++- packages/web/src/components/task-sidebar.tsx | 21 +++- .../web/src/lib/sandbox-instance-mode-copy.ts | 4 +- 5 files changed, 161 insertions(+), 90 deletions(-) diff --git a/packages/server/scripts/probe-gateway-port.ts b/packages/server/scripts/probe-gateway-port.ts index c5a0760..169265e 100644 --- a/packages/server/scripts/probe-gateway-port.ts +++ b/packages/server/scripts/probe-gateway-port.ts @@ -1,43 +1,61 @@ -import { readFileSync, existsSync } from 'node:fs' -import { resolve, dirname } from 'node:path' -import { fileURLToPath } from 'node:url' +/** + * 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 + */ -const __dirname = dirname(fileURLToPath(import.meta.url)) -const envPath = resolve(__dirname, '../.env') -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() - if (!process.env[k]) process.env[k] = t.slice(eq + 1).trim() - } -} +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 taskId = process.argv[2] || '1uup1280sermpmd7e6u' +const here = dirname(fileURLToPath(import.meta.url)) +config({ path: resolve(here, '../.env') }) async function main() { - const { getDb } = await import('../src/db/index.js') - const { getTaskSandbox } = await import('../src/sandbox/task-sandbox.js') - const { resolveGatewayPreviewPort } = await import('../src/sandbox/ttyd-gateway-port.js') - const { TTYD_VIRTUAL_PORT } = await import('../src/sandbox/ttyd-preview.js') - const task = await getDb().tasks.findById(taskId) - const sandbox = await getTaskSandbox(task!, process.env.TCB_ENV_ID || '', { - isCodingMode: task!.mode === 'coding', - }) - if (!sandbox) throw new Error('no sandbox') - const portsRes = await sandbox.request('/preview/ports') - const portsJson = await portsRes.json() - const gw = await resolveGatewayPreviewPort(sandbox, TTYD_VIRTUAL_PORT) - const auth = await sandbox.getAuthHeaders() - const url = `${sandbox.baseUrl}/preview/${gw}/` - const res = await fetch(url, { headers: auth }) - const text = await res.text() - console.log(JSON.stringify({ gw, portsJson, upstream: { status: res.status, ttyd: text.includes('ttyd') } }, null, 2)) + 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 as Error).message) + console.error(e) process.exit(1) }) diff --git a/packages/server/src/routes/tasks.ts b/packages/server/src/routes/tasks.ts index e78e776..716bbde 100644 --- a/packages/server/src/routes/tasks.ts +++ b/packages/server/src/routes/tasks.ts @@ -522,7 +522,7 @@ async function cleanupSandboxAfterTaskDelete(existing: Task, envId: string): Pro async function deleteTaskForUser( existing: Task, envId: string, - opts?: { awaitSandbox?: boolean }, + opts?: { awaitSandbox?: boolean; skipSandboxCleanup?: boolean }, ): Promise<DeleteTaskSuccess | { ok: false; failure: DeleteTaskFailure }> { const taskId = existing.id let provisionFailed: DeleteTaskSuccess['provisionFailed'] @@ -567,16 +567,18 @@ async function deleteTaskForUser( await getDb().tasks.softDelete(taskId) - if (opts?.awaitSandbox) { - try { - await cleanupSandboxAfterTaskDelete(existing, envId) - } catch { - console.log('clean conversation workspace error') + 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') + }) } - } else { - void cleanupSandboxAfterTaskDelete(existing, envId).catch(() => { - console.log('clean conversation workspace error') - }) } if (provisionFailed?.length) { @@ -589,26 +591,32 @@ async function deleteTaskForUser( return { ok: true } } -tasksRouter.delete('/', requireUserEnv, async (c) => { - const session = c.get('session')! - const { envId } = c.get('userEnv')! - const action = c.req.query('action') ?? '' +/** 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)) - 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 - let deleted = 0 - const failures: Array<{ taskId: string; error: string; detail?: string }> = [] +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 (const task of toDelete) { - const result = await deleteTaskForUser(task, envId, { awaitSandbox: true }) + 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 }) } @@ -621,6 +629,42 @@ tasksRouter.delete('/', requireUserEnv, async (c) => { }) } } + } + + 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) { @@ -659,28 +703,7 @@ tasksRouter.delete('/', requireUserEnv, async (c) => { const candidates = await getDb().tasks.findAll(500, 0, { userId: session.user.id }) const toDelete = candidates.filter((t) => statusSet.has(t.status)) - let deleted = 0 - const failures: Array<{ taskId: string; error: string; detail?: string }> = [] - - for (const task of toDelete) { - const result = await deleteTaskForUser(task, envId) - if (result.ok) { - deleted += 1 - 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, - }) - } - } + const { deleted, failures } = await deleteTasksInBatch(toDelete, envId, { awaitSandbox: false }) if (deleted === 0 && failures.length > 0) { return c.json( diff --git a/packages/web/src/components/app-layout.tsx b/packages/web/src/components/app-layout.tsx index 0153a55..8461f8f 100644 --- a/packages/web/src/components/app-layout.tsx +++ b/packages/web/src/components/app-layout.tsx @@ -1,4 +1,4 @@ -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' @@ -17,6 +17,8 @@ interface AppLayoutProps { interface TasksContextType { refreshTasks: () => Promise<void> + clearAllTasksOptimistically: () => void + revertTasksOptimistically: () => void toggleSidebar: () => void isSidebarOpen: boolean isSidebarResizing: boolean @@ -94,6 +96,7 @@ function SidebarLoader({ width }: { width: number }) { export function AppLayout({ children, initialSidebarWidth, initialSidebarOpen, initialIsMobile }: AppLayoutProps) { const [tasks, setTasks] = useState<Task[]>([]) + const tasksSnapshotForRevertRef = useRef<Task[]>([]) const [isLoading, setIsLoading] = useState(true) // Initialize sidebar state based on user agent and preferences // On mobile (from user agent): always closed @@ -260,6 +263,20 @@ export function AppLayout({ children, initialSidebarWidth, initialSidebarOpen, i return { id, optimisticTask } } + const clearAllTasksOptimistically = useCallback(() => { + setTasks((prev) => { + tasksSnapshotForRevertRef.current = prev + return [] + }) + }, []) + + const revertTasksOptimistically = useCallback(() => { + const snapshot = tasksSnapshotForRevertRef.current + if (snapshot.length === 0) return + setTasks(snapshot) + tasksSnapshotForRevertRef.current = [] + }, []) + const closeSidebar = () => { updateSidebarOpen(false, false) // Don't save to cookie for mobile backdrop clicks } @@ -305,6 +322,8 @@ export function AppLayout({ children, initialSidebarWidth, initialSidebarOpen, i <TasksContext.Provider value={{ refreshTasks: fetchTasks, + clearAllTasksOptimistically, + revertTasksOptimistically, toggleSidebar, isSidebarOpen, isSidebarResizing: isResizing, diff --git a/packages/web/src/components/task-sidebar.tsx b/packages/web/src/components/task-sidebar.tsx index 2acc3e4..9c8c922 100644 --- a/packages/web/src/components/task-sidebar.tsx +++ b/packages/web/src/components/task-sidebar.tsx @@ -3,7 +3,7 @@ import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { AlertCircle, Plus, Trash2, GitBranch, Loader2, Search, X, MoreVertical, Smartphone, Clock } from 'lucide-react' import { cn } from '@/lib/utils' -import { Link, useLocation } from 'react-router' +import { Link, useLocation, useNavigate } from 'react-router' import { Claude, CodeBuddy, Codex, Copilot, Cursor, Gemini, OpenCode } from '@/components/logos' import { AlertDialog, @@ -99,7 +99,8 @@ interface GitHubRepoInfo { export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { const { pathname } = useLocation() - const { refreshTasks, toggleSidebar } = useTasks() + const navigate = useNavigate() + const { refreshTasks, clearAllTasksOptimistically, revertTasksOptimistically, toggleSidebar } = useTasks() const session = useAtomValue(sessionAtom) const githubConnection = useAtomValue(githubConnectionAtom) const [isDeleting, setIsDeleting] = useState(false) @@ -340,7 +341,12 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { const handleDeleteAllTasks = async () => { if (activeTaskCount === 0) return + const onTaskPage = pathname.startsWith('/tasks/') + setShowDeleteDialog(false) setIsDeleting(true) + clearAllTasksOptimistically() + if (onTaskPage) navigate('/') + try { const response = await fetch('/api/tasks?action=all', { method: 'DELETE', @@ -350,18 +356,23 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { if (response.ok) { const result = await response.json() const failed = Array.isArray(result.failed) ? result.failed.length : 0 + const deleted = typeof result.deleted === 'number' ? result.deleted : 0 if (failed > 0) { toast.warning(`${result.message ?? '已删除'}(${failed} 项警告/失败)`) } else { toast.success(result.message ?? '已删除全部任务') } + if (deleted === 0) { + revertTasksOptimistically() + } await refreshTasks() - setShowDeleteDialog(false) } else { - const error = await response.json() + revertTasksOptimistically() + const error = await response.json().catch(() => ({})) toast.error(error.error || '删除全部任务失败') } } catch (error) { + revertTasksOptimistically() console.error('Error deleting all tasks:', error) toast.error('删除全部任务失败') } finally { @@ -794,7 +805,7 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { disabled={isDeleting || activeTaskCount === 0} className="bg-red-600 hover:bg-red-700" > - {isDeleting ? (sandboxInstanceMode === 'isolated' ? '正在逐个删除…' : '正在删除…') : '删除全部'} + {isDeleting ? '正在删除…' : '删除全部'} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> diff --git a/packages/web/src/lib/sandbox-instance-mode-copy.ts b/packages/web/src/lib/sandbox-instance-mode-copy.ts index a2873a1..59f4816 100644 --- a/packages/web/src/lib/sandbox-instance-mode-copy.ts +++ b/packages/web/src/lib/sandbox-instance-mode-copy.ts @@ -10,14 +10,14 @@ export function newTaskTooltip(mode: SandboxInstanceMode): string { export function deleteAllTasksTooltip(mode: SandboxInstanceMode, taskCount: number): string { if (taskCount === 0) return '当前没有可删除的任务' if (mode === 'isolated') { - return `当前:隔离实例模式。将删除全部 ${taskCount} 个任务,并逐个停止对应沙箱实例;任务较多时可能较慢。` + return `当前:隔离实例模式。将删除全部 ${taskCount} 个任务;沙箱实例在后台依次回收。` } return `当前:共享实例模式。将删除全部 ${taskCount} 个任务,并停止本环境共享沙箱实例以回收计算资源。` } export function deleteAllTasksDialogBody(mode: SandboxInstanceMode, taskCount: number): string { if (mode === 'isolated') { - return `将永久删除 ${taskCount} 个任务。每个任务绑定的沙箱实例会依次停止并销毁,任务较多时请耐心等待。此操作不可撤销。` + return `将永久删除 ${taskCount} 个任务。沙箱实例会在后台回收,列表会立即更新。此操作不可撤销。` } return `将永久删除 ${taskCount} 个任务,并停止本环境的共享沙箱实例(同环境下其他用户也无法再使用该实例)。此操作不可撤销。` } From a1ac3f5b6916f8bf03a6bcac8a966e12e7b568c9 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Tue, 26 May 2026 20:00:13 +0800 Subject: [PATCH 20/29] fix(web): shared Jotai store for task list and instant delete-all Use appJotaiStore with JotaiProvider so sidebar loads real tasks instead of stuck skeletons. Centralize list state in task-list-store with optimistic clear on delete-all and stale-fetch guards. Stream logs to UI immediately, stabilize terminal probe against refresh loops, and align tasks API listing. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../src/agent/cloudbase-agent.service.ts | 10 +- .../src/agent/runtime/opencode-acp-runtime.ts | 8 +- packages/server/src/lib/append-task-log.ts | 22 ++ packages/server/src/routes/tasks.ts | 128 +++++++-- packages/server/src/sandbox/git-archive.ts | 14 +- packages/shared/src/index.ts | 1 + packages/shared/src/task-log-messages.ts | 47 +++ packages/web/src/components/app-layout.tsx | 272 +++++------------- packages/web/src/components/file-browser.tsx | 8 + packages/web/src/components/file-editor.tsx | 6 + packages/web/src/components/logs-pane.tsx | 40 ++- packages/web/src/components/task-details.tsx | 34 ++- .../web/src/components/task-page-client.tsx | 2 +- packages/web/src/components/task-sidebar.tsx | 48 ++-- packages/web/src/components/terminal.tsx | 90 +++--- packages/web/src/hooks/use-chat-stream.ts | 9 +- packages/web/src/hooks/use-tasks.ts | 29 +- packages/web/src/lib/append-task-log.ts | 17 ++ packages/web/src/lib/atoms/task-list.ts | 6 + packages/web/src/lib/jotai-store.ts | 4 + packages/web/src/lib/push-live-task-log.ts | 30 ++ packages/web/src/lib/task-list-api.ts | 19 ++ packages/web/src/lib/task-list-store.ts | 76 +++++ packages/web/src/main.tsx | 3 +- packages/web/src/pages/TasksListPage.tsx | 18 +- 25 files changed, 598 insertions(+), 343 deletions(-) create mode 100644 packages/server/src/lib/append-task-log.ts create mode 100644 packages/shared/src/task-log-messages.ts create mode 100644 packages/web/src/lib/append-task-log.ts create mode 100644 packages/web/src/lib/atoms/task-list.ts create mode 100644 packages/web/src/lib/jotai-store.ts create mode 100644 packages/web/src/lib/push-live-task-log.ts create mode 100644 packages/web/src/lib/task-list-api.ts create mode 100644 packages/web/src/lib/task-list-store.ts diff --git a/packages/server/src/agent/cloudbase-agent.service.ts b/packages/server/src/agent/cloudbase-agent.service.ts index 3a188ab..31d0c02 100644 --- a/packages/server/src/agent/cloudbase-agent.service.ts +++ b/packages/server/src/agent/cloudbase-agent.service.ts @@ -27,7 +27,8 @@ import { import { decrypt } from '../lib/crypto.js' import { encryptJWE } from '../lib/session.js' import type { AgentCallbackMessage, AgentOptions, CodeBuddyMessage, ExtendedSessionUpdate } from '@coder/shared' -import { isSandboxToolName, sandboxLogMessageForTool } 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' @@ -1916,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) } diff --git a/packages/server/src/agent/runtime/opencode-acp-runtime.ts b/packages/server/src/agent/runtime/opencode-acp-runtime.ts index 14ca400..e6d03fa 100644 --- a/packages/server/src/agent/runtime/opencode-acp-runtime.ts +++ b/packages/server/src/agent/runtime/opencode-acp-runtime.ts @@ -47,6 +47,8 @@ import { OpencodeMessageBuilder, findLastRecordIds, buildHistoryContextPrompt } import { BaseAgentRuntime } from './base-runtime.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' @@ -589,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/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<void> { + 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/routes/tasks.ts b/packages/server/src/routes/tasks.ts index 716bbde..d2a9356 100644 --- a/packages/server/src/routes/tasks.ts +++ b/packages/server/src/routes/tasks.ts @@ -5,6 +5,9 @@ import { nanoid } from 'nanoid' import { requireAuth, requireUserEnv, type AppEnv } from '../middleware/auth' import { readFileSync } from 'node:fs' import { createTaskLogger } from '../lib/task-logger' +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, @@ -210,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) : [], @@ -466,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 }) @@ -670,6 +673,9 @@ tasksRouter.delete('/', requireUserEnv, async (c) => { 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: '*', @@ -1417,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) + 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) return c.json({ error: 'Sandbox not available' }, 400) + 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) + } + + await appendAllowedTaskLog(taskId, 'success', TASK_LOG.WORKSPACE_FILE_SAVED) - // 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 + 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) } }) @@ -1448,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) + 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) return c.json({ success: false, error: 'Sandbox not found or inactive' }, 400) + 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 runCommandInSandbox(sandbox, `mkdir -p '${dirPath.replace(/'/g, "'\\''")}'`) - if (!mkdirResult.success) return c.json({ success: false, error: 'Failed to create parent directories' }, 500) + 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) return c.json({ success: false, error: 'Failed to create file' }, 500) + if (!touchResult.success) { + await appendAllowedTaskLog(taskId, 'error', TASK_LOG.WORKSPACE_FILE_CREATE_FAILED) + 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) } }) @@ -1478,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) + 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) return c.json({ success: false, error: 'Sandbox not found or inactive' }, 400) + 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) return c.json({ success: false, error: 'Failed to create folder' }, 500) + 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) } }) @@ -1503,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) + 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) return c.json({ success: false, error: 'Sandbox not found or inactive' }, 400) + 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) return c.json({ success: false, error: 'Failed to delete file' }, 500) + 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) } }) @@ -2415,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) { @@ -2426,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 } }) } } @@ -2444,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) @@ -2594,7 +2651,7 @@ tasksRouter.post('/:taskId/start-sandbox', requireUserEnv, async (c) => { }), ) await getDb().tasks.update(taskId, { sandboxId: sandbox.id, updatedAt: Date.now() }) - await logger.info('Sandbox started successfully') + 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) @@ -2671,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 // --------------------------------------------------------------------------- diff --git a/packages/server/src/sandbox/git-archive.ts b/packages/server/src/sandbox/git-archive.ts index 33f74a6..dce06c9 100644 --- a/packages/server/src/sandbox/git-archive.ts +++ b/packages/server/src/sandbox/git-archive.ts @@ -117,17 +117,19 @@ export async function injectGitArchiveWorkspaceEnv(sandbox: SandboxInstance): Pr * @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<void> { - if (!conversationId) return +): Promise<ArchiveToGitResult> { + if (!conversationId) return 'skipped' const config = getConfig() if (!config) { console.log('[GitArchive] Not configured, skipping archive') - return + return 'skipped' } try { @@ -144,11 +146,13 @@ export async function archiveToGit( 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') + return 'ok' } + console.warn('[GitArchive] Push failed') + return 'fail' } catch (err) { console.error('[GitArchive] Error:', (err as Error)?.message) + return 'fail' } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7b04266..53ecf97 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,3 +5,4 @@ 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/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<string>(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/web/src/components/app-layout.tsx b/packages/web/src/components/app-layout.tsx index 8461f8f..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, 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 @@ -17,8 +14,6 @@ interface AppLayoutProps { interface TasksContextType { refreshTasks: () => Promise<void> - clearAllTasksOptimistically: () => void - revertTasksOptimistically: () => void toggleSidebar: () => void isSidebarOpen: boolean isSidebarResizing: boolean @@ -46,61 +41,7 @@ function generateId(): string { return Math.random().toString(36).substring(2) + Date.now().toString(36) } -function SidebarLoader({ width }: { width: number }) { - return ( - <div - className="h-full border-r bg-muted px-2 md:px-3 pt-3 md:pt-5.5 pb-3 md:pb-4 overflow-y-auto" - style={{ width: `${width}px` }} - > - <div className="mb-3 md:mb-4"> - <div className="flex items-center justify-between mb-2"> - {/* Tabs */} - <div className="flex items-center gap-1"> - <button - className="text-xs font-medium tracking-wide transition-colors px-2 py-1 rounded text-foreground bg-accent" - disabled - > - Tasks - </button> - {/* <button - className="text-xs font-medium tracking-wide transition-colors px-2 py-1 rounded text-muted-foreground" - disabled - > - Repos - </button> */} - </div> - <div className="flex items-center gap-1"> - <Button variant="ghost" size="sm" className="h-8 w-8 p-0" disabled={true} title="Delete Tasks"> - <Trash2 className="h-4 w-4" /> - </Button> - <Link to="/"> - <Button variant="ghost" size="sm" className="h-8 w-8 p-0" title="New Task"> - <Plus className="h-4 w-4" /> - </Button> - </Link> - </div> - </div> - </div> - - <div className="space-y-1"> - {/* Loading skeleton for tasks */} - {Array.from({ length: 3 }).map((_, i) => ( - <Card key={i} className="animate-pulse h-[70px] rounded-lg"> - <CardContent className="px-3 py-2">{/* Empty skeleton - just the card shape */}</CardContent> - </Card> - ))} - </div> - </div> - ) -} - export function AppLayout({ children, initialSidebarWidth, initialSidebarOpen, initialIsMobile }: AppLayoutProps) { - const [tasks, setTasks] = useState<Task[]>([]) - const tasksSnapshotForRevertRef = useRef<Task[]>([]) - 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 @@ -110,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) }, []) @@ -166,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]) @@ -189,96 +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 } - } - - const clearAllTasksOptimistically = useCallback(() => { - setTasks((prev) => { - tasksSnapshotForRevertRef.current = prev - return [] - }) - }, []) - - const revertTasksOptimistically = useCallback(() => { - const snapshot = tasksSnapshotForRevertRef.current - if (snapshot.length === 0) return - setTasks(snapshot) - tasksSnapshotForRevertRef.current = [] - }, []) + 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) => { @@ -289,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) @@ -321,9 +199,7 @@ export function AppLayout({ children, initialSidebarWidth, initialSidebarOpen, i return ( <TasksContext.Provider value={{ - refreshTasks: fetchTasks, - clearAllTasksOptimistically, - revertTasksOptimistically, + refreshTasks, toggleSidebar, isSidebarOpen, isSidebarResizing: isResizing, @@ -340,10 +216,8 @@ export function AppLayout({ children, initialSidebarWidth, initialSidebarOpen, i } as React.CSSProperties } > - {/* Backdrop - Mobile Only */} {isSidebarOpen && <div className="lg:hidden fixed inset-0 bg-black/50 z-30" onClick={closeSidebar} />} - {/* Sidebar */} <div className={` fixed inset-y-0 left-0 z-40 @@ -351,21 +225,13 @@ export function AppLayout({ children, initialSidebarWidth, initialSidebarOpen, i ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'} ${isSidebarOpen ? 'pointer-events-auto' : 'pointer-events-none'} `} - style={{ - width: `${sidebarWidth}px`, - }} + style={{ width: `${sidebarWidth}px` }} > - <div - className="h-full overflow-hidden" - style={{ - width: `${sidebarWidth}px`, - }} - > - {isLoading ? <SidebarLoader width={sidebarWidth} /> : <TaskSidebar tasks={tasks} width={sidebarWidth} />} + <div className="h-full overflow-hidden" style={{ width: `${sidebarWidth}px` }}> + <TaskSidebar width={sidebarWidth} /> </div> </div> - {/* Resize Handle - Desktop Only, when sidebar is open */} <div className={` hidden lg:block fixed inset-y-0 cursor-col-resize group z-50 hover:bg-primary/20 @@ -373,21 +239,15 @@ export function AppLayout({ children, initialSidebarWidth, initialSidebarOpen, i ${isSidebarOpen ? 'w-1 opacity-100' : 'w-0 opacity-0'} `} onMouseDown={isSidebarOpen ? handleMouseDown : undefined} - style={{ - // Position it right after the sidebar - left: isSidebarOpen ? `${sidebarWidth}px` : '0px', - }} + style={{ left: isSidebarOpen ? `${sidebarWidth}px` : '0px' }} > <div className="absolute inset-0 w-2 -ml-0.5" /> <div className="absolute inset-y-0 left-0 w-0.5 bg-primary/50 opacity-0 group-hover:opacity-100 transition-opacity" /> </div> - {/* Main Content */} <div className={`flex-1 overflow-auto flex flex-col ${isResizing || !hasMounted ? '' : 'transition-all duration-300 ease-in-out'}`} - style={{ - marginLeft: isDesktop && isSidebarOpen ? `${sidebarWidth + 4}px` : '0px', - }} + style={{ marginLeft: isDesktop && isSidebarOpen ? `${sidebarWidth + 4}px` : '0px' }} > {children} </div> diff --git a/packages/web/src/components/file-browser.tsx b/packages/web/src/components/file-browser.tsx index 510832b..0d760b0 100644 --- a/packages/web/src/components/file-browser.tsx +++ b/packages/web/src/components/file-browser.tsx @@ -22,7 +22,9 @@ import { Button } from '@/components/ui/button' import { useAtom } from 'jotai' import { getTaskFileBrowserState } from '@/lib/atoms/file-browser' import { useMemo } from 'react' +import { TASK_LOG } from '@coder/shared' import { toast } from 'sonner' +import { pushLiveTaskLog } from '@/lib/push-live-task-log' import { DropdownMenu, DropdownMenuContent, @@ -556,6 +558,7 @@ export function FileBrowser({ const result = await response.json() if (!response.ok || !result.success) throw new Error(result.error || 'Failed to create file') + pushLiveTaskLog(taskId, { type: 'success', message: TASK_LOG.WORKSPACE_FILE_CREATED }, { persist: false }) toast.success('文件创建成功') setShowNewFileDialog(false) setNewFileName('') @@ -570,6 +573,7 @@ export function FileBrowser({ } } catch (err) { console.error('Error creating file:', err) + pushLiveTaskLog(taskId, { type: 'error', message: TASK_LOG.WORKSPACE_FILE_CREATE_FAILED }) toast.error(err instanceof Error ? err.message : 'Failed to create file') } finally { setIsCreatingFile(false) @@ -598,6 +602,7 @@ export function FileBrowser({ const result = await response.json() if (!response.ok || !result.success) throw new Error(result.error || 'Failed to create folder') + pushLiveTaskLog(taskId, { type: 'success', message: TASK_LOG.WORKSPACE_FOLDER_CREATED }, { persist: false }) toast.success('文件夹创建成功') setShowNewFolderDialog(false) setNewFolderName('') @@ -614,6 +619,7 @@ export function FileBrowser({ } } catch (err) { console.error('Error creating folder:', err) + pushLiveTaskLog(taskId, { type: 'error', message: TASK_LOG.WORKSPACE_FOLDER_CREATE_FAILED }) toast.error(err instanceof Error ? err.message : 'Failed to create folder') } finally { setIsCreatingFolder(false) @@ -636,6 +642,7 @@ export function FileBrowser({ const result = await response.json() if (!response.ok || !result.success) throw new Error(result.error || 'Failed to delete file') + pushLiveTaskLog(taskId, { type: 'success', message: TASK_LOG.WORKSPACE_FILE_DELETED }, { persist: false }) toast.success('文件删除成功') setShowDeleteConfirm(false) setFileToDelete(null) @@ -647,6 +654,7 @@ export function FileBrowser({ } } catch (err) { console.error('Error deleting file:', err) + pushLiveTaskLog(taskId, { type: 'error', message: TASK_LOG.WORKSPACE_FILE_DELETE_FAILED }) toast.error(err instanceof Error ? err.message : 'Failed to delete file') } finally { setIsDeleting(false) diff --git a/packages/web/src/components/file-editor.tsx b/packages/web/src/components/file-editor.tsx index 63fc69f..d3b29a3 100644 --- a/packages/web/src/components/file-editor.tsx +++ b/packages/web/src/components/file-editor.tsx @@ -1,5 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' +import { TASK_LOG } from '@coder/shared' import { toast } from 'sonner' +import { pushLiveTaskLog } from '@/lib/push-live-task-log' import Editor, { type OnMount } from '@monaco-editor/react' // Monaco types for editor and monaco instances @@ -192,15 +194,19 @@ export function FileEditor({ if (response.ok && data.success) { setSavedContent(currentContent) + toast.success('文件已保存') + pushLiveTaskLog(taskId, { type: 'success', message: TASK_LOG.WORKSPACE_FILE_SAVED }, { persist: false }) // Notify parent component of successful save if (onSaveSuccessRef.current) { onSaveSuccessRef.current() } } else { + pushLiveTaskLog(taskId, { type: 'error', message: TASK_LOG.WORKSPACE_FILE_SAVE_FAILED }, { persist: false }) toast.error(data.error || 'Failed to save file') } } catch (error) { console.error('Error saving file:', error) + pushLiveTaskLog(taskId, { type: 'error', message: TASK_LOG.WORKSPACE_FILE_SAVE_FAILED }) toast.error('Failed to save file') } finally { setIsSaving(false) diff --git a/packages/web/src/components/logs-pane.tsx b/packages/web/src/components/logs-pane.tsx index 958dbec..16e7d31 100644 --- a/packages/web/src/components/logs-pane.tsx +++ b/packages/web/src/components/logs-pane.tsx @@ -1,8 +1,15 @@ -import type { Task, LogEntry } from '@coder/shared' +import type { Task } from '@coder/shared' +import { + isServerTaskLogMessage, + isWorkspaceTaskLogMessage, + LOG_PREFIX_SERVER, + LOG_PREFIX_WORKSPACE, + type LogEntry, +} from '@coder/shared' import { Button } from '@/components/ui/button' import { Copy, Check, ChevronDown, ChevronUp, Trash2 } from 'lucide-react' import { cn } from '@/lib/utils' -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { useSetAtom } from 'jotai' import { toast } from 'sonner' import { useTasks } from '@/components/app-layout' @@ -14,17 +21,19 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ interface LogsPaneProps { task: Task onHeightChange?: (height: number) => void + /** Sync persisted task.logs after clear (e.g. useTask refetch) — avoids full sidebar refresh. */ + onTaskLogsSync?: () => void | Promise<void> } type TabType = 'logs' | 'terminal' -type LogFilterType = 'all' | 'platform' | 'server' +type LogFilterType = 'all' | 'platform' | 'workspace' | 'server' /** ttyd/xterm needs a real pixel height; smaller panes render a blank black iframe */ const TERMINAL_PANE_MIN_HEIGHT = 200 const PANE_HEIGHT_MIN = 100 const PANE_HEIGHT_MAX_CAP = 600 -export function LogsPane({ task, onHeightChange }: LogsPaneProps) { +export function LogsPane({ task, onHeightChange, onTaskLogsSync }: LogsPaneProps) { const [copiedLogs, setCopiedLogs] = useState(false) const [isCollapsed, setIsCollapsedState] = useState(true) const [paneHeight, setPaneHeight] = useState(200) @@ -39,7 +48,7 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { const prevLogsLengthRef = useRef<number>(0) const hasInitialScrolled = useRef<boolean>(false) const wasAtBottomRef = useRef<boolean>(true) - const { isSidebarOpen, isSidebarResizing, refreshTasks } = useTasks() + const { isSidebarOpen, isSidebarResizing } = useTasks() const setStreamLogs = useSetAtom(streamLogsAtomFamily(task.id)) // Check if we're on desktop @@ -165,9 +174,11 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { // Helper function to filter logs based on current filter const getFilteredLogs = (filter: LogFilterType) => { return (task.logs || []).filter((log) => { - const isServerLog = log.message.startsWith('[SERVER]') - if (filter === 'server') return isServerLog - if (filter === 'platform') return !isServerLog + const server = isServerTaskLogMessage(log.message) + const workspace = isWorkspaceTaskLogMessage(log.message) + if (filter === 'server') return server + if (filter === 'workspace') return workspace + if (filter === 'platform') return !server && !workspace return true }) } @@ -196,7 +207,7 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { if (response.ok) { setStreamLogs([]) - refreshTasks() + await onTaskLogsSync?.() } else { const error = await response.json() toast.error(error.error || 'Failed to clear logs') @@ -311,6 +322,7 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { <SelectContent> <SelectItem value="all">All</SelectItem> <SelectItem value="platform">Platform</SelectItem> + <SelectItem value="workspace">Workspace</SelectItem> <SelectItem value="server">Server</SelectItem> </SelectContent> </Select> @@ -364,8 +376,13 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { </div> ) : null} {getFilteredLogs(logFilter).map((log, index) => { - const isServerLog = log.message.startsWith('[SERVER]') - const messageContent = isServerLog ? log.message.substring(9) : log.message // Remove '[SERVER] ' + const isServerLog = isServerTaskLogMessage(log.message) + const isWorkspaceLog = isWorkspaceTaskLogMessage(log.message) + const messageContent = isServerLog + ? log.message.slice(LOG_PREFIX_SERVER.length).trimStart() + : isWorkspaceLog + ? log.message.slice(LOG_PREFIX_WORKSPACE.length).trimStart() + : log.message const getLogColor = (logType: LogEntry['type']) => { switch (logType) { @@ -395,6 +412,7 @@ export function LogsPane({ task, onHeightChange }: LogsPaneProps) { <div key={index} className={cn('flex gap-1.5 leading-tight')}> <span className="text-white/40 text-[10px] shrink-0">[{formatTime(log.timestamp || Date.now())}]</span> <span className={cn('flex-1', getLogColor(log.type))}> + {isWorkspaceLog && <span className="text-amber-400">[工作区]</span>} {isServerLog && <span className="text-purple-400">[SERVER]</span>} {isServerLog && ' '} {messageContent} diff --git a/packages/web/src/components/task-details.tsx b/packages/web/src/components/task-details.tsx index 53a8d71..0d982b2 100644 --- a/packages/web/src/components/task-details.tsx +++ b/packages/web/src/components/task-details.tsx @@ -54,6 +54,8 @@ import { sessionAtom } from '@/lib/atoms/session' import { toast } from 'sonner' import { Claude, CodeBuddy, Codex, Copilot, Cursor, Gemini, OpenCode } from '@/components/logos' import { useTasks } from '@/components/app-layout' +import { TASK_LOG } from '@coder/shared' +import { pushLiveTaskLog } from '@/lib/push-live-task-log' import { getShowFilesPane, setShowFilesPane as saveShowFilesPane, @@ -376,6 +378,9 @@ export function TaskDetails({ const [previewCurrentPath, setPreviewCurrentPath] = useState<string | undefined>(undefined) const previewIframeRef = useRef<HTMLIFrameElement | null>(null) const previewAbortRef = useRef<AbortController | null>(null) + const previewReadyLoggedRef = useRef(false) + const previewRestartLoggedRef = useRef(false) + const { refreshTasks } = useTasks() // ── 预览错误自动修复 ──────────────────────────────────────────── // 两个触发源: @@ -583,6 +588,11 @@ export function TaskDetails({ const data = (await res.json()) as { status: string; vitePort?: number | null } if (data.status === 'stopped' && !cancelled) { console.log('[preview] Dev server stopped, restarting...') + if (!previewRestartLoggedRef.current) { + previewRestartLoggedRef.current = true + previewReadyLoggedRef.current = false + pushLiveTaskLog(task.id, { type: 'info', message: TASK_LOG.PLATFORM_PREVIEW_RESTARTING }) + } if (interval) clearInterval(interval) setPreviewLoadingMessage('Dev server 已停止,正在重启...') void loadPreviewGatewayUrl() @@ -598,6 +608,11 @@ export function TaskDetails({ if (!res.ok) return const data = (await res.json()) as { status: string } if (data.status === 'stopped' && !cancelled) { + if (!previewRestartLoggedRef.current) { + previewRestartLoggedRef.current = true + previewReadyLoggedRef.current = false + pushLiveTaskLog(task.id, { type: 'info', message: TASK_LOG.PLATFORM_PREVIEW_RESTARTING }) + } if (interval) clearInterval(interval) setPreviewLoadingMessage('Dev server 已停止,正在重启...') void loadPreviewGatewayUrl() @@ -697,9 +712,19 @@ export function TaskDetails({ const fileSearchRef = useRef<HTMLDivElement>(null) const tabsContainerRef = useRef<HTMLDivElement>(null) const tabButtonRefs = useRef<{ [key: string]: HTMLButtonElement | null }>({}) - const { refreshTasks } = useTasks() const navigate = useNavigate() + useEffect(() => { + if (!previewGatewayUrl) { + previewReadyLoggedRef.current = false + return + } + if (previewReadyLoggedRef.current) return + previewReadyLoggedRef.current = true + previewRestartLoggedRef.current = false + pushLiveTaskLog(task.id, { type: 'success', message: TASK_LOG.PLATFORM_PREVIEW_READY }) + }, [previewGatewayUrl, task.id]) + // Tabs state for Code pane - each mode has its own tabs and selection const [openTabsByMode, setOpenTabsByMode] = useState<{ local: string[] @@ -1870,8 +1895,7 @@ export function TaskDetails({ if (response.ok) { toast.success('沙箱停止成功!') - // 刷新任务以更新 UI - await refreshTasks() + onTaskRefetch?.() } else { const error = await response.json() toast.error(error.error || '停止沙箱失败') @@ -1893,8 +1917,8 @@ export function TaskDetails({ if (response.ok) { toast.success('沙箱启动成功!') - // 刷新任务以更新 UI - await refreshTasks() + pushLiveTaskLog(task.id, { type: 'success', message: TASK_LOG.PLATFORM_SANDBOX_STARTED }, { persist: false }) + onTaskRefetch?.() } else { const error = await response.json() toast.error(error.error || '启动沙箱失败') diff --git a/packages/web/src/components/task-page-client.tsx b/packages/web/src/components/task-page-client.tsx index 07e03e6..c8f19cd 100644 --- a/packages/web/src/components/task-page-client.tsx +++ b/packages/web/src/components/task-page-client.tsx @@ -146,7 +146,7 @@ export function TaskPageClient({ /> </div> - <LogsPane task={taskForLogsPane ?? task} onHeightChange={setLogsPaneHeight} /> + <LogsPane task={taskForLogsPane ?? task} onHeightChange={setLogsPaneHeight} onTaskLogsSync={refetch} /> </div> ) } diff --git a/packages/web/src/components/task-sidebar.tsx b/packages/web/src/components/task-sidebar.tsx index 9c8c922..56aa9d7 100644 --- a/packages/web/src/components/task-sidebar.tsx +++ b/packages/web/src/components/task-sidebar.tsx @@ -27,6 +27,8 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { useState, useMemo, useEffect, useRef, useCallback } from 'react' import { toast } from 'sonner' import { useTasks } from '@/components/app-layout' +import { taskListAtom, taskListLoadingAtom } from '@/lib/atoms/task-list' +import { clearTaskListNow, finishDeleteAll } from '@/lib/task-list-store' import { useAtomValue } from 'jotai' import { sessionAtom } from '@/lib/atoms/session' import { PRStatusIcon } from '@/components/pr-status-icon' @@ -80,7 +82,6 @@ const AGENT_MODELS = { } as const interface TaskSidebarProps { - tasks: Task[] width?: number } @@ -97,10 +98,12 @@ interface GitHubRepoInfo { language?: string } -export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { +export function TaskSidebar({ width = 288 }: TaskSidebarProps) { const { pathname } = useLocation() const navigate = useNavigate() - const { refreshTasks, clearAllTasksOptimistically, revertTasksOptimistically, toggleSidebar } = useTasks() + const { refreshTasks, toggleSidebar } = useTasks() + const tasks = useAtomValue(taskListAtom) + const isInitialLoading = useAtomValue(taskListLoadingAtom) const session = useAtomValue(sessionAtom) const githubConnection = useAtomValue(githubConnectionAtom) const [isDeleting, setIsDeleting] = useState(false) @@ -122,7 +125,8 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { const [searchHasMore, setSearchHasMore] = useState(false) const loadMoreRef = useRef<HTMLDivElement>(null) - const activeTaskCount = useMemo(() => tasks.filter((t) => !t.deletedAt).length, [tasks]) + const visibleTasks = useMemo(() => tasks.filter((t) => !t.deletedAt), [tasks]) + const activeTaskCount = visibleTasks.length useEffect(() => { if (!session.user) return @@ -145,7 +149,7 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { const taskCountByRepo = useMemo(() => { const counts = new Map<string, number>() - tasks.forEach((task) => { + visibleTasks.forEach((task) => { if (task.repoUrl) { try { const url = new URL(task.repoUrl) @@ -163,7 +167,7 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { }) return counts - }, [tasks]) + }, [visibleTasks]) // Debounce search query useEffect(() => { @@ -343,10 +347,11 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { const onTaskPage = pathname.startsWith('/tasks/') setShowDeleteDialog(false) + clearTaskListNow() setIsDeleting(true) - clearAllTasksOptimistically() if (onTaskPage) navigate('/') + let deleteSucceeded = false try { const response = await fetch('/api/tasks?action=all', { method: 'DELETE', @@ -356,26 +361,22 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { if (response.ok) { const result = await response.json() const failed = Array.isArray(result.failed) ? result.failed.length : 0 - const deleted = typeof result.deleted === 'number' ? result.deleted : 0 + const deleted = Number(result.deleted) || 0 + deleteSucceeded = deleted > 0 if (failed > 0) { toast.warning(`${result.message ?? '已删除'}(${failed} 项警告/失败)`) } else { toast.success(result.message ?? '已删除全部任务') } - if (deleted === 0) { - revertTasksOptimistically() - } - await refreshTasks() } else { - revertTasksOptimistically() const error = await response.json().catch(() => ({})) toast.error(error.error || '删除全部任务失败') } } catch (error) { - revertTasksOptimistically() console.error('Error deleting all tasks:', error) toast.error('删除全部任务失败') } finally { + await finishDeleteAll(deleteSucceeded) setIsDeleting(false) } } @@ -548,7 +549,15 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { {/* Tasks Tab Content */} {activeTab === 'tasks' && ( <div className="space-y-1"> - {tasks.length === 0 ? ( + {isInitialLoading && visibleTasks.length === 0 ? ( + <> + {Array.from({ length: 3 }).map((_, i) => ( + <Card key={i} className="animate-pulse h-[70px] rounded-lg"> + <CardContent className="px-3 py-2" /> + </Card> + ))} + </> + ) : visibleTasks.length === 0 ? ( <Card> <CardContent className="p-3 text-center text-xs text-muted-foreground"> No tasks yet. Create your first task! @@ -556,7 +565,7 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { </Card> ) : ( <> - {tasks.slice(0, 10).map((task) => { + {visibleTasks.slice(0, 10).map((task) => { const isActive = pathname === `/tasks/${task.id}` return ( @@ -665,7 +674,7 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { </Link> ) })} - {tasks.length >= 1 && ( + {visibleTasks.length >= 1 && ( <div className="pt-1"> <Link to="/tasks" onClick={handleLinkClick}> <Button variant="ghost" size="sm" className="w-full justify-start h-7 px-2 text-xs"> @@ -801,7 +810,10 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { <AlertDialogFooter> <AlertDialogCancel>取消</AlertDialogCancel> <AlertDialogAction - onClick={handleDeleteAllTasks} + onClick={(e) => { + e.preventDefault() + void handleDeleteAllTasks() + }} disabled={isDeleting || activeTaskCount === 0} className="bg-red-600 hover:bg-red-700" > diff --git a/packages/web/src/components/terminal.tsx b/packages/web/src/components/terminal.tsx index 712227c..74760b1 100644 --- a/packages/web/src/components/terminal.tsx +++ b/packages/web/src/components/terminal.tsx @@ -5,6 +5,8 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' import { Loader2, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' +import { TASK_LOG } from '@coder/shared' +import { pushLiveTaskLog } from '@/lib/push-live-task-log' /** Must match packages/server/src/sandbox/ttyd-preview.ts TTYD_VIRTUAL_PORT */ const TTYD_PREVIEW_PORT = 7681 @@ -50,6 +52,9 @@ export const Terminal = forwardRef<TerminalRef, TerminalProps>(function Terminal const pollRef = useRef(0) const probeGenerationRef = useRef(0) const iframeRef = useRef<HTMLIFrameElement>(null) + const platformLogRef = useRef<'none' | 'ready' | 'unavailable'>('none') + const gateStatusRef = useRef<TerminalGateStatus>('idle') + gateStatusRef.current = gateStatus const iframeSrc = `/api/tasks/${taskId}/preview/${TTYD_PREVIEW_PORT}/` @@ -87,39 +92,58 @@ export const Terminal = forwardRef<TerminalRef, TerminalProps>(function Terminal } }, [sandboxReady, taskId]) - const runProbeLoop = useCallback(async () => { - const generation = ++probeGenerationRef.current - pollRef.current = 0 - setGateStatus(sandboxReady ? 'checking' : 'no_sandbox') - if (!sandboxReady) return - - while (pollRef.current < MAX_POLLS) { - if (generation !== probeGenerationRef.current) return - - const status = await checkTerminal() - if (generation !== probeGenerationRef.current) return - - if (status === 'ready') { - setGateStatus('ready') - return + const runProbeLoop = useCallback( + async (options?: { force?: boolean }) => { + // Parent re-renders (e.g. refreshTasks) must not tear down a working iframe. + if (!options?.force && gateStatusRef.current === 'ready') return + + const generation = ++probeGenerationRef.current + pollRef.current = 0 + setGateStatus(sandboxReady ? 'checking' : 'no_sandbox') + if (!sandboxReady) return + + while (pollRef.current < MAX_POLLS) { + if (generation !== probeGenerationRef.current) return + + const status = await checkTerminal() + if (generation !== probeGenerationRef.current) return + + if (status === 'ready') { + setGateStatus('ready') + if (platformLogRef.current !== 'ready') { + platformLogRef.current = 'ready' + pushLiveTaskLog(taskId, { type: 'success', message: TASK_LOG.PLATFORM_TERMINAL_READY }) + } + return + } + if (status === 'no_sandbox') { + setGateStatus('no_sandbox') + platformLogRef.current = 'none' + return + } + if (status === 'unavailable' || status === 'error') { + setGateStatus(status) + if (platformLogRef.current !== 'unavailable') { + platformLogRef.current = 'unavailable' + pushLiveTaskLog(taskId, { type: 'error', message: TASK_LOG.PLATFORM_TERMINAL_UNAVAILABLE }) + } + return + } + + setGateStatus('starting') + pollRef.current += 1 + await new Promise((r) => setTimeout(r, POLL_MS)) } - if (status === 'no_sandbox') { - setGateStatus('no_sandbox') - return + if (generation === probeGenerationRef.current) { + setGateStatus('unavailable') + if (platformLogRef.current !== 'unavailable') { + platformLogRef.current = 'unavailable' + pushLiveTaskLog(taskId, { type: 'error', message: TASK_LOG.PLATFORM_TERMINAL_UNAVAILABLE }) + } } - if (status === 'unavailable' || status === 'error') { - setGateStatus(status) - return - } - - setGateStatus('starting') - pollRef.current += 1 - await new Promise((r) => setTimeout(r, POLL_MS)) - } - if (generation === probeGenerationRef.current) { - setGateStatus('unavailable') - } - }, [checkTerminal, sandboxReady]) + }, + [checkTerminal, sandboxReady, taskId], + ) useEffect(() => { if (!isActive) { @@ -132,6 +156,7 @@ export const Terminal = forwardRef<TerminalRef, TerminalProps>(function Terminal useEffect(() => { probeGenerationRef.current += 1 + platformLogRef.current = 'none' setIframeEpoch((n) => n + 1) }, [taskId]) @@ -149,7 +174,8 @@ export const Terminal = forwardRef<TerminalRef, TerminalProps>(function Terminal const handleRetry = () => { setIframeEpoch((n) => n + 1) probeGenerationRef.current += 1 - void runProbeLoop() + platformLogRef.current = 'none' + void runProbeLoop({ force: true }) } if (!isActive) { diff --git a/packages/web/src/hooks/use-chat-stream.ts b/packages/web/src/hooks/use-chat-stream.ts index 2bf31d3..046c21f 100644 --- a/packages/web/src/hooks/use-chat-stream.ts +++ b/packages/web/src/hooks/use-chat-stream.ts @@ -18,6 +18,7 @@ import type { TaskMessage, AskUserQuestionData, ToolConfirmData, DeploymentInfo, import { planModeAtomFamily } from '@/lib/atoms/plan-mode' import { streamLogsAtomFamily } from '@/lib/atoms/stream-logs' import type { LogEntry } from '@coder/shared' +import { pushLiveTaskLog } from '@/lib/push-live-task-log' import { AcpClient } from '@/lib/acp' import { applySessionUpdate, IDLE_AGENT_PHASE, type AgentPhaseInfo } from './apply-session-update' @@ -173,13 +174,9 @@ export function useChatStream(taskId: string, options: UseChatStreamOptions = {} const appendStreamLog = useCallback( (entry: LogEntry) => { - setStreamLogs((prev) => { - const last = prev[prev.length - 1] - if (last && last.message === entry.message && last.type === entry.type) return prev - return [...prev, entry] - }) + pushLiveTaskLog(taskId, entry, { persist: false }) }, - [setStreamLogs], + [taskId], ) /** Dispatch a single SSE sessionUpdate event to the appropriate state setter. */ diff --git a/packages/web/src/hooks/use-tasks.ts b/packages/web/src/hooks/use-tasks.ts index c729abb..2ef00f5 100644 --- a/packages/web/src/hooks/use-tasks.ts +++ b/packages/web/src/hooks/use-tasks.ts @@ -1,25 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' -import { api } from '../lib/api' -import type { Task } from '@coder/shared' - -export function useTasks() { - const [tasks, setTasks] = useState<Task[]>([]) - const [isLoading, setIsLoading] = useState(true) - - const fetchTasks = useCallback(async () => { - try { - const data = await api.get<{ tasks: Task[] }>('/api/tasks') - setTasks(data.tasks) - } catch { - setTasks([]) - } finally { - setIsLoading(false) - } - }, []) - - useEffect(() => { - fetchTasks() - }, [fetchTasks]) - - return { tasks, isLoading, refreshTasks: fetchTasks } -} +/** + * @deprecated Use `useTasks` from `@/components/app-layout` (single task list source). + */ +export { useTasks } from '@/components/app-layout' diff --git a/packages/web/src/lib/append-task-log.ts b/packages/web/src/lib/append-task-log.ts new file mode 100644 index 0000000..64b2728 --- /dev/null +++ b/packages/web/src/lib/append-task-log.ts @@ -0,0 +1,17 @@ +import type { LogEntry } from '@coder/shared' +import { isAllowedTaskLogMessage } from '@coder/shared' + +/** Persist a whitelisted static log line for the task (Logs pane). */ +export async function appendTaskLog(taskId: string, entry: Pick<LogEntry, 'type' | 'message'>): Promise<void> { + if (!isAllowedTaskLogMessage(entry.message)) return + try { + await fetch(`/api/tasks/${taskId}/append-log`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(entry), + }) + } catch { + // Non-critical + } +} diff --git a/packages/web/src/lib/atoms/task-list.ts b/packages/web/src/lib/atoms/task-list.ts new file mode 100644 index 0000000..0ceb332 --- /dev/null +++ b/packages/web/src/lib/atoms/task-list.ts @@ -0,0 +1,6 @@ +import { atom } from 'jotai' +import type { Task } from '@coder/shared' + +/** Single source of truth for sidebar + /tasks list (Jotai store, not React context). */ +export const taskListAtom = atom<Task[]>([]) +export const taskListLoadingAtom = atom(false) diff --git a/packages/web/src/lib/jotai-store.ts b/packages/web/src/lib/jotai-store.ts new file mode 100644 index 0000000..9f6c351 --- /dev/null +++ b/packages/web/src/lib/jotai-store.ts @@ -0,0 +1,4 @@ +import { createStore } from 'jotai' + +/** Shared with <JotaiProvider store={appJotaiStore}> — do not use getDefaultStore() in app code. */ +export const appJotaiStore = createStore() diff --git a/packages/web/src/lib/push-live-task-log.ts b/packages/web/src/lib/push-live-task-log.ts new file mode 100644 index 0000000..b558e79 --- /dev/null +++ b/packages/web/src/lib/push-live-task-log.ts @@ -0,0 +1,30 @@ +import { appJotaiStore } from '@/lib/jotai-store' +import type { LogEntry } from '@coder/shared' +import { appendTaskLog } from '@/lib/append-task-log' +import { streamLogsAtomFamily } from '@/lib/atoms/stream-logs' + +export type PushLiveTaskLogOptions = { + /** + * Persist via POST /append-log (default true). + * Set false when the server already wrote the same line to avoid duplicate DB rows. + */ + persist?: boolean +} + +function appendDeduped(prev: LogEntry[], entry: LogEntry): LogEntry[] { + if (prev.some((e) => e.type === entry.type && e.message === entry.message)) return prev + return [...prev, entry] +} + +/** Show a log line in Logs pane immediately (streamLogs) and optionally persist. */ +export function pushLiveTaskLog( + taskId: string, + entry: Pick<LogEntry, 'type' | 'message'>, + options?: PushLiveTaskLogOptions, +): void { + const line: LogEntry = { ...entry, timestamp: Date.now() } + appJotaiStore.set(streamLogsAtomFamily(taskId), (prev) => appendDeduped(prev, line)) + if (options?.persist !== false) { + void appendTaskLog(taskId, entry) + } +} diff --git a/packages/web/src/lib/task-list-api.ts b/packages/web/src/lib/task-list-api.ts new file mode 100644 index 0000000..91c6e7f --- /dev/null +++ b/packages/web/src/lib/task-list-api.ts @@ -0,0 +1,19 @@ +import type { Task } from '@coder/shared' + +export function filterActiveTasks(tasks: Task[]): Task[] { + return tasks.filter((t) => !t.deletedAt) +} + +export async function fetchActiveTasks(signal?: AbortSignal): Promise<Task[]> { + const response = await fetch('/api/tasks', { + credentials: 'include', + cache: 'no-store', + signal, + }) + if (!response.ok) { + if (response.status === 401) return [] + throw new Error('Failed to fetch tasks') + } + const data = await response.json() + return filterActiveTasks((data.tasks ?? []) as Task[]) +} diff --git a/packages/web/src/lib/task-list-store.ts b/packages/web/src/lib/task-list-store.ts new file mode 100644 index 0000000..9110be5 --- /dev/null +++ b/packages/web/src/lib/task-list-store.ts @@ -0,0 +1,76 @@ +import type { Task } from '@coder/shared' +import { taskListAtom, taskListLoadingAtom } from '@/lib/atoms/task-list' +import { appJotaiStore } from '@/lib/jotai-store' +import { fetchActiveTasks } from '@/lib/task-list-api' + +let fetchGeneration = 0 +let loadAbort: AbortController | null = null + +function commitTasks(gen: number, tasks: Task[]): void { + if (gen !== fetchGeneration) return + appJotaiStore.set(taskListAtom, tasks) +} + +/** Instant empty sidebar — safe to call synchronously on delete-all confirm. */ +export function clearTaskListNow(): void { + fetchGeneration += 1 + loadAbort?.abort() + loadAbort = null + appJotaiStore.set(taskListAtom, []) + appJotaiStore.set(taskListLoadingAtom, false) +} + +export async function loadTaskList(): Promise<void> { + const gen = ++fetchGeneration + loadAbort?.abort() + const controller = new AbortController() + loadAbort = controller + + const showSkeleton = appJotaiStore.get(taskListAtom).length === 0 + if (showSkeleton) { + appJotaiStore.set(taskListLoadingAtom, true) + } + + try { + const tasks = await fetchActiveTasks(controller.signal) + if (controller.signal.aborted) return + commitTasks(gen, tasks) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') return + console.error('Error loading task list:', error) + } finally { + if (!controller.signal.aborted && gen === fetchGeneration) { + appJotaiStore.set(taskListLoadingAtom, false) + } + } +} + +export async function finishDeleteAll(deleteSucceeded: boolean): Promise<void> { + loadAbort?.abort() + const gen = ++fetchGeneration + const controller = new AbortController() + loadAbort = controller + + try { + const tasks = await fetchActiveTasks(controller.signal) + if (controller.signal.aborted) return + + if (deleteSucceeded) { + commitTasks(gen, []) + } else { + commitTasks(gen, tasks) + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') return + console.error('Error refreshing task list after delete all:', error) + } finally { + if (!controller.signal.aborted && gen === fetchGeneration) { + appJotaiStore.set(taskListLoadingAtom, false) + } + } +} + +export function prependTask(task: Task): void { + const prev = appJotaiStore.get(taskListAtom) + appJotaiStore.set(taskListAtom, [task, ...prev]) +} diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 9c471ad..86aa3d9 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client' import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router' import { Toaster } from '@/components/ui/sonner' import { Provider as JotaiProvider, useAtom, useAtomValue } from 'jotai' +import { appJotaiStore } from './lib/jotai-store' import { AppLayout } from './components/app-layout' import { HomePage } from './pages/HomePage' import { TaskPage } from './pages/TaskPage' @@ -364,7 +365,7 @@ function App() { createRoot(document.getElementById('root')!).render( <StrictMode> - <JotaiProvider> + <JotaiProvider store={appJotaiStore}> <ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange> <BrowserRouter> <App /> diff --git a/packages/web/src/pages/TasksListPage.tsx b/packages/web/src/pages/TasksListPage.tsx index 4d0a706..aaa8573 100644 --- a/packages/web/src/pages/TasksListPage.tsx +++ b/packages/web/src/pages/TasksListPage.tsx @@ -1,6 +1,8 @@ import { useState, useMemo } from 'react' import { useNavigate } from 'react-router' -import { useTasks } from '@/hooks/use-tasks' +import { useAtomValue } from 'jotai' +import { useTasks } from '@/components/app-layout' +import { taskListAtom, taskListLoadingAtom } from '@/lib/atoms/task-list' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' @@ -104,7 +106,9 @@ function getHumanFriendlyModelName(agent: string | null | undefined, model: stri export function TasksListPage() { const navigate = useNavigate() - const { tasks, isLoading, refreshTasks } = useTasks() + const { refreshTasks } = useTasks() + const tasks = useAtomValue(taskListAtom) + const isInitialLoading = useAtomValue(taskListLoadingAtom) const [selectedTasks, setSelectedTasks] = useState<Set<string>>(new Set()) const [statusFilter, setStatusFilter] = useState<string>('all') const [showDeleteDialog, setShowDeleteDialog] = useState(false) @@ -112,10 +116,12 @@ export function TasksListPage() { const [isDeleting, setIsDeleting] = useState(false) const [isStopping, setIsStopping] = useState(false) + const activeTasks = useMemo(() => tasks.filter((task: Task) => !task.deletedAt), [tasks]) + const filteredTasks = useMemo(() => { - if (statusFilter === 'all') return tasks - return tasks.filter((task: Task) => task.status === statusFilter) - }, [tasks, statusFilter]) + if (statusFilter === 'all') return activeTasks + return activeTasks.filter((task: Task) => task.status === statusFilter) + }, [activeTasks, statusFilter]) const handleSelectAll = () => { if (selectedTasks.size === filteredTasks.length) { @@ -283,7 +289,7 @@ export function TasksListPage() { </div> {/* Tasks List */} - {isLoading ? ( + {isInitialLoading ? ( <div className="flex items-center justify-center h-64"> <div className="text-muted-foreground">Loading tasks...</div> </div> From 182b7f0e3483be692d5f18e11b19f7899e3d27d7 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Wed, 27 May 2026 18:50:00 +0800 Subject: [PATCH 21/29] refactor(env): split local vs cloud env files and streamline init Move secrets to root .env.local (dev) and .env.cloud (deploy), remove packages/server/.env.example, and update init/deploy/setup scripts accordingly. Add masked secret prompts via @inquirer/password and default CodeBuddy custom models to off in init. Co-authored-by: Cursor <cursoragent@cursor.com> --- .env.example | 120 ++- .gitignore | 2 +- AGENTS.md | 2 +- README-zh.md | 118 ++- README.md | 10 +- docs/cloudrun-deploy.md | 46 +- docs/setup.md | 108 +-- init.sh | 2 + package.json | 2 +- packages/server/.env.example | 26 - packages/server/package.json | 2 +- .../server/scripts/debug-start-instance.ts | 6 +- .../server/scripts/describe-stateful-tool.ts | 6 +- packages/server/scripts/probe-gateway-port.ts | 2 +- .../server/scripts/stop-stateful-instances.ts | 11 +- .../server/scripts/test-acp-chat-http.mts | 2 +- packages/server/scripts/test-acp-http-e2e.mts | 2 +- .../server/scripts/test-memory-flow-e2e.mts | 2 +- .../test-opencode-coding-preview-e2e.mts | 4 +- .../scripts/test-opencode-sandbox-e2e.mts | 4 +- .../server/scripts/test-persistence-e2e.mts | 2 +- .../scripts/test-persistence-suspend-e2e.mts | 2 +- .../server/scripts/test-runtime-selector.mts | 2 +- .../scripts/update-stateful-tool-image.ts | 8 +- .../scripts/update-stateful-tool-ports.ts | 6 +- .../server/scripts/verify-stateful-e2e.ts | 6 +- packages/server/src/lib/sandbox-config.ts | 2 +- packages/server/src/routes/auth.ts | 2 +- .../src/sandbox/ensure-stateful-tool.ts | 6 +- .../src/sandbox/provider/stateful-provider.ts | 6 +- .../web/src/pages/admin/settings-page.tsx | 4 +- pnpm-lock.yaml | 85 +++ scripts/codebuddy-setup.mjs | 119 +-- scripts/deploy.mjs | 137 ++-- scripts/init.mjs | 689 ++++++++---------- scripts/lib/env-files.mjs | 66 ++ scripts/lib/prompt.mjs | 74 ++ scripts/opencode-setup.mjs | 131 ++-- scripts/reset-user-cam-secrets.ts | 4 +- scripts/setup-tcr.mjs | 62 +- 40 files changed, 918 insertions(+), 972 deletions(-) delete mode 100644 packages/server/.env.example create mode 100644 scripts/lib/env-files.mjs create mode 100644 scripts/lib/prompt.mjs diff --git a/.env.example b/.env.example index bc78519..1fc8405 100644 --- a/.env.example +++ b/.env.example @@ -1,94 +1,90 @@ -# ============================================================ -# .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 | https://*.run.tcloudbase.com | + +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 ==================== - -MAX_MESSAGES_PER_DAY=50 -MAX_SANDBOX_DURATION=300 - -# ==================== Sandbox ==================== - -# 沙箱实例隔离(AGS;与 TCB_PROVISION_MODE 不是一回事): -# shared - 同一 envId 下多任务共享实例(默认) -# isolated - 每 task 独立实例 -WORKSPACE_ISOLATION=shared - -# AGS 实例最长存活(秒),映射 StartSandboxInstance.Timeout / Tool DefaultTimeout(默认 1800 = 30m) -# SANDBOX_TTL_SECONDS=1800 -# 沙箱数据面 gateway(写入 packages/server/.env,init 会生成) -# TCB_API_KEY= +# ==================== Stateful sandbox ==================== -# 实例鉴权(默认关闭;开启后必须配置 TCB_ACCESS_TOKEN,sit_*,用于 X-Access-Token) +TCB_API_KEY= # ENABLE_AUTH_MODE=false # TCB_ACCESS_TOKEN= - -# 沙箱业务镜像 URI(可选;不配则用代码内公开 TCR 默认) # STATEFUL_SANDBOX_IMAGE= -# Vite 原生错误 overlay 开关(创建沙箱时注入,需要重建沙箱生效) -# 设为 false 可关闭预览中 Vite 自带的全屏错误遮罩,改由平台侧 banner 展示构建错误 -# VITE_DEV_OVERLAY=false - -# ==================== TCR ==================== - -# TCR 容器镜像配置(由 pnpm setup:tcr 自动写入) -# TCR_NAMESPACE= -# TCR_PASSWORD= -# TCR_IMAGE= - -# ==================== GitHub OAuth (Optional) ==================== +# ==================== Optional ==================== # GITHUB_CLIENT_ID= # GITHUB_CLIENT_SECRET= - -# ==================== Git Archive (Optional) ==================== - -# 工作区 Git 归档配置 # GIT_ARCHIVE_REPO= # GIT_ARCHIVE_USER= # GIT_ARCHIVE_TOKEN= +# http_proxy= -# ==================== Proxy (Optional) ==================== +# ==================== TCR (optional, setup-tcr.mjs) ==================== -# http_proxy= +# TCR_NAMESPACE= +# TCR_PASSWORD= +# TCR_IMAGE= diff --git a/.gitignore b/.gitignore index f7fb4e5..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 diff --git a/AGENTS.md b/AGENTS.md index 494a73c..72adfef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,7 +134,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/README-zh.md b/README-zh.md index 28eac3e..8a24353 100644 --- a/README-zh.md +++ b/README-zh.md @@ -113,73 +113,73 @@ --- -## 本地启动(最小路径) - -**前置**:Node **22.x**(`.nvmrc`)、pnpm 10+、CloudBase 支撑环境 + API 密钥、CodeBuddy 或 OpenCode API Key(`npm i -g opencode-ai`)。本地只跑 OpenVibeCoding 前后端;编码沙箱在云端 AGS + 沙箱业务镜像。 - -**Agent**:任务可选 **CodeBuddy** 或 **OpenCode**(`opencode-acp`),二者共用同一套 AGS 沙箱与沙箱业务镜像(`BaseAgentRuntime.setupSandbox`)。 +## 初始化 ```bash git clone https://github.com/TencentCloudBase/OpenVibeCoding.git cd OpenVibeCoding ./init.sh -pnpm dev # Web http://localhost:5174 API http://localhost:3001/api ``` -**`packages/server/.env` 最少**(`init.sh` 会写入大部分): +`init.sh` 开头会让你 **二选一**(没有「两个都生成」): -| 变量 | 作用 | -| --- | --- | -| `JWE_SECRET` / `ENCRYPTION_KEY` | 会话加密 | -| `TCB_ENV_ID` / `TCB_SECRET_ID` / `TCB_SECRET_KEY` | 管理面 | -| `TCB_API_KEY` | 沙箱业务镜像 数据面 gateway | -| `CODEBUDDY_API_KEY` | CodeBuddy Agent | -| (可选)本机 `opencode` / `opencode-ai` CLI | OpenCode Agent | +| 选项 | 生成 | 用途 | +| --- | --- | --- | +| **1** | `.env.local` | 本地 `pnpm dev` | +| **2** | `.env.cloud` | `pnpm deploy:cloud`(CLI 凭证 + 同步到云托管运行时) | -Tool 名 `openvibecoding-{TCB_ENV_ID}` 自动创建/复用,勿配 `STATEFUL_TOOL_ID`。模板见 [packages/server/.env.example](packages/server/.env.example)。排障:[docs/setup.md](docs/setup.md)。 +本地 + 云端都要用时:**跑两次** `./init.sh`,各选一次。字段说明见 [.env.example](.env.example);流程见 [docs/setup.md](docs/setup.md#配置文件职责)。 --- -## 开发 +## 本地开发 + +**前置**:Node **22.x**(`.nvmrc`)、pnpm 10+、CloudBase 支撑环境 + API 密钥、CodeBuddy 或 OpenCode API Key(`npm i -g opencode-ai`)。本机只跑 OpenVibeCoding 前后端;编码沙箱在云端 Stateful + 沙箱业务镜像。 + +**环境**:只读根目录 **`.env.local`**(`pnpm dev:server` → `--env-file=../../.env.local`)。Vite 把 `/api` 代理到 `:3001`,前端不需要单独 env 文件。 + +**Agent**:任务可选 **CodeBuddy** 或 **OpenCode**(`opencode-acp`),共用同一套沙箱。 ```bash -pnpm dev # 同时启动 web (localhost:5174) 和 server (localhost:3001) -pnpm dev:web # 仅启动前端 -pnpm dev:server # 仅启动后端 +pnpm dev # Web http://localhost:5174 API http://localhost:3001/api ``` -## 生产 +| 变量(在 `.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)。 ```bash -pnpm build # 构建所有包 -pnpm start # 启动生产服务(端口 3001,同时服务 API 和静态文件) +pnpm dev:web # 仅前端 +pnpm dev:server # 仅后端 +pnpm build && pnpm start # 本机生产形态(仍读进程环境,非云托管) ``` -## 部署到云托管 +--- -本项目支持一键部署到 CloudBase 云托管(容器服务)。无需本地 Docker —— 脚本会将源码和 Dockerfile 提交到云端构建。 +## 部署到云托管 -**前置条件** +与本地开发 **分开**:不跑 `pnpm dev`,用 CloudBase CLI 提交镜像,运行时 env 来自 **`.env.cloud`**(`pnpm deploy:cloud` 在部署后 API 同步,不必手抄控制台)。 -- 已完成 `./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` +- 核对 `.env.cloud` 中 `ASK_USER_BASE_URL` 为公网根 URL(可先占位,首次部署后按控制台域名改再部署) ```bash pnpm deploy:cloud ``` -脚本会自动执行: -1. 提交源码 + Dockerfile 到云端构建镜像 -2. 部署为云托管容器服务(服务名:`vibecoding-platform`,端口:80) -3. 查询并输出服务的访问地址 - -**部署完成后** +1. 用 `.env.cloud` 的凭证提交源码 + `Dockerfile` 云端构建 +2. 部署服务 `vibecoding-platform`(端口 80) +3. 将 `.env.cloud` 同步到云托管 EnvParams(`--skip-env-sync` 可跳过) -- 访问地址格式:`https://{serviceName}-{id}.{region}.run.tcloudbase.com` -- 构建进度可在 [云开发控制台](https://tcb.cloud.tencent.com) → 云托管 → 服务详情 → 部署记录 中查看 -- 环境变量见 [docs/cloudrun-deploy.md](docs/cloudrun-deploy.md)(`PORT=80`、`ASK_USER_BASE_URL` 公网 URL 等) +详情:[docs/cloudrun-deploy.md](docs/cloudrun-deploy.md)。构建进度:控制台 → 云托管 → 部署记录。 ## 常用命令 @@ -246,35 +246,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= -``` +- 模板(可提交):[.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) --- @@ -302,7 +278,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` ### 生成结果示例 @@ -328,7 +304,7 @@ pnpm opencode:setup ``` ```bash -# packages/server/.env 会追加 API Key +# .env.local 会追加 API Key CLOUDBASE_API_KEY=eyJhbGciOiJS.xxxxxxxx ``` @@ -372,7 +348,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 读取 @@ -397,7 +373,7 @@ pnpm codebuddy:setup ``` ```bash -# packages/server/.env 会自动追加 +# .env.local 会自动追加 CLOUDBASE_API_KEY=eyJhbGciOiJS.xxxxxxxx CODEBUDDY_USE_CUSTOM_MODELS=true ``` @@ -434,7 +410,7 @@ packages/server/.config/.codebuddy/models.json } ``` -同时确保在 `packages/server/.env` 中提供对应的环境变量,并设置: +同时确保在 `.env.local` 中提供对应的环境变量,并设置: ```bash CODEBUDDY_USE_CUSTOM_MODELS=true diff --git a/README.md b/README.md index 7de763b..4bbf4ff 100644 --- a/README.md +++ b/README.md @@ -302,7 +302,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 +328,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 +368,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 +393,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 +429,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/cloudrun-deploy.md b/docs/cloudrun-deploy.md index 88ca838..6911b63 100644 --- a/docs/cloudrun-deploy.md +++ b/docs/cloudrun-deploy.md @@ -1,12 +1,16 @@ # CloudRun 部署(云托管) -OpenVibeCoding 以根目录 `Dockerfile` 构建**前后端一体**容器,监听 **80**。环境变量在控制台配置,**不要**把 `packages/server/.env` 打进镜像(`.dockerignore` 已排除)。 +一体容器(`Dockerfile`)监听 **80**。密钥**不打进镜像**(`.dockerignore` 排除 `.env*`)。 -## 前置 +## 环境文件 -- 已完成 `./init.sh`(或手动具备 `TCB_ENV_ID`、`TCB_SECRET_ID`、`TCB_SECRET_KEY`) -- `packages/server/.env` 中沙箱与 Agent 相关变量与本地一致(部署后在控制台再填一份) -- 已安装 CloudBase CLI:`npm i -g @cloudbase/cli` 且 `cloudbase login` +| 文件 | 用途 | +| --- | --- | +| `.env.example` | 文档模板(可提交) | +| `.env.local` | 仅本地 `pnpm dev` | +| `.env.cloud` | `pnpm deploy:cloud` 读此文件(CLI 凭证 + 部署后同步到服务的运行时变量) | + +`./init.sh` 每次只生成其一:选 1 → `.env.local`,选 2 → `.env.cloud`;两份都要则跑两次 init。云端差异主要是 `PORT`、`NODE_ENV`、`ASK_USER_BASE_URL`。 ## 一键部署 @@ -14,26 +18,24 @@ OpenVibeCoding 以根目录 `Dockerfile` 构建**前后端一体**容器,监 pnpm deploy:cloud ``` -- 服务名默认:`vibecoding-platform` -- 云端从源码 + `Dockerfile` 构建,无需本机 Docker -- 构建进度:控制台 → 云托管 → 服务详情 → 部署记录 - -## 控制台必改项(相对本地) +1. 使用 `.env.cloud` 中的 `TCB_*` 调用 CloudBase CLI 上传源码并云端构建 +2. 部署完成后将 `.env.cloud` 同步到服务 `EnvParams`(无需手抄控制台) +3. 若 API 同步失败,脚本会提示到控制台粘贴 `.env.cloud` -| 变量 | 值 | -| --- | --- | -| `PORT` | `80` | -| `NODE_ENV` | `production` | -| `ASK_USER_BASE_URL` | 云托管公网根 URL(如 `https://xxx.run.tcloudbase.com`),**不能**用 `127.0.0.1` | +跳过环境变量同步(仅上传代码): -其余与本地 `packages/server/.env` 同名:`TCB_*`、`TCB_API_KEY`、`CODEBUDDY_*`、可选 `GIT_ARCHIVE_*` 等。勿配置 `STATEFUL_TOOL_ID`(多副本应走 DB + `openvibecoding-{TCB_ENV_ID}` Tool 名)。 +```bash +pnpm deploy:cloud --skip-env-sync +``` -## 部署后验证 +## 部署后 -1. 打开控制台给出的默认域名,`GET /health` 为 ok -2. 登录并创建任务,确认沙箱进度与预览 -3. 若沙箱失败,对照 [docs/setup.md](./setup.md) 沙箱排障;本地可用 `pnpm --filter @coder/server stop:stateful-instances` +- 构建进度:控制台 → 云托管 → 服务 `vibecoding-platform` → 部署记录 +- 确认 `ASK_USER_BASE_URL` 为公网根 URL(勿用 `127.0.0.1`) +- 勿配置 `STATEFUL_TOOL_ID`(多副本用 DB + `openvibecoding-{TCB_ENV_ID}`) -## 与上游 main +## 验证 -`scripts/deploy.mjs` 与上游 `TencentCloudBase/OpenVibeCoding` `main` 对齐;合并上游后优先保留本分支 stateful 相关 env 说明。 +1. `GET /health` +2. 登录并创建任务,检查沙箱与预览 +3. 沙箱失败见 [setup.md](./setup.md) 排障 diff --git a/docs/setup.md b/docs/setup.md index 7eeb101..e625f88 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -12,7 +12,7 @@ ### 必需项 - **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 已安装并已启动(**stateful 分支**:本地只跑 OpenVibeCoding,沙箱连云端沙箱 infra + 沙箱业务镜像,无需本机 `tcb-sandbox serve`) +- Docker **仅在你选择 init 里的 TCR 推镜像时需要**(日常 Stateful 开发不需要) - 腾讯云账号,且已准备 CloudBase 环境 - 可用的腾讯云 API 密钥(`SecretId` / `SecretKey`) - 至少一种 CodeBuddy 认证方式: @@ -54,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[完成初始化] ``` ## 各步骤说明 @@ -70,35 +73,51 @@ 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)。 + +## 本地开发流程 + +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 认证配置 -- 数据库提供方配置 -- Stateful 沙箱 infra(控制面 + 沙箱业务镜像 数据面)/ TCR 镜像配置 -- 可选的 GitHub OAuth、代理配置 +与本地开发分开,见 [cloudrun-deploy.md](./cloudrun-deploy.md): -初始化脚本会优先把 CloudBase 和服务端相关配置写入这里。 +1. 确认 `.env.cloud` 含 `TCB_SECRET_*`、`TCB_ENV_ID`(init 选 2 生成) +2. 编辑 `.env.cloud`(尤其 `ASK_USER_BASE_URL`) +3. `pnpm deploy:cloud` → 构建 + 同步 `.env.cloud` 到服务 ## 关键环境变量 @@ -132,7 +151,7 @@ flowchart TD | `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 同名;`SANDBOX_INSTANCE_MODE` 仍可读作兼容别名) | +| `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` | @@ -154,7 +173,7 @@ flowchart TD | `shared`(默认) | 同一支撑 `TCB_ENV_ID` 下,多任务复用沙箱 infra 上同一运行实例(`ensureSingleEnvInstance`) | | `isolated` | 每任务独立实例;优先复用任务上的 `sandboxId`,否则新建 | -配置位置:`packages/server/.env` 的 `WORKSPACE_ISOLATION`;Admin「系统设置」里的 `sandbox_instance_mode`(DB)优先级更高。改模式后**新建任务**最可靠;旧任务若 DB 里已写死 `sandboxMode` 可能仍为旧值。 +配置位置:`.env.local` 的 `WORKSPACE_ISOLATION`;Admin「系统设置」里的 `sandbox_instance_mode`(DB)优先级更高。改模式后**新建任务**最可靠;旧任务若 DB 里已写死 `sandboxMode` 可能仍为旧值。 ## 用户环境模式 @@ -184,8 +203,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 镜像相关配置 @@ -308,7 +328,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` 只在启动时加载一次)。 @@ -317,7 +337,7 @@ pnpm opencode:setup | 文件 | 作用 | 是否 gitignore | |---|---|---| | `.opencode/opencode.json` | provider + model 定义(opencode 子进程 + server 均读取) | 否(应提交) | -| `packages/server/.env` | API Key 等凭证 | 是 | +| `.env.local` | API Key 等凭证 | 是 | ### 常见问题 @@ -348,7 +368,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 读取 @@ -359,7 +379,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` 开关 | 是 | ### 同步与自定义模型规则 @@ -391,7 +411,7 @@ packages/server/.config/.codebuddy/models.json } ``` -同时确保在 `packages/server/.env` 中提供对应的环境变量,并设置: +同时确保在 `.env.local` 中提供对应的环境变量,并设置: ```bash CODEBUDDY_USE_CUSTOM_MODELS=true @@ -402,7 +422,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` | @@ -411,7 +431,7 @@ CODEBUDDY_USE_CUSTOM_MODELS=true 如果不使用交互式脚本,建议按照以下顺序手动处理: 1. 准备 `.env.local` -2. 准备 `packages/server/.env` +2. 准备 `.env.local` 3. 安装依赖 4. 配置 CodeBuddy 认证 5. 配置 TCR 镜像 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 866d99d..527f95d 100644 --- a/package.json +++ b/package.json @@ -26,13 +26,13 @@ "deploy:cloud": "node scripts/deploy.mjs", "opencode:setup": "node scripts/opencode-setup.mjs", "codebuddy:setup": "node scripts/codebuddy-setup.mjs", - "deploy:cloud": "node scripts/deploy.mjs", "prepare": "husky" }, "devDependencies": { "@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/.env.example b/packages/server/.env.example deleted file mode 100644 index 6408197..0000000 --- a/packages/server/.env.example +++ /dev/null @@ -1,26 +0,0 @@ -# Server runtime — copy to .env or run ./init.sh (writes this file). -# Local: pnpm dev:server loads via --env-file=.env - -# --- Required --- -PORT=3001 -JWE_SECRET= -ENCRYPTION_KEY= -TCB_ENV_ID= -TCB_SECRET_ID= -TCB_SECRET_KEY= -TCB_API_KEY= -# ENABLE_AUTH_MODE=false -# TCB_ACCESS_TOKEN= # required when ENABLE_AUTH_MODE=true (sit_*) -CODEBUDDY_API_KEY= - -# --- Sandbox (AGS + 沙箱业务镜像); first task may CreateSandboxTool once per env --- -# SANDBOX_TTL_SECONDS=1800 # AGS instance TTL (seconds); default 30m -# WORKSPACE_ISOLATION=shared -# STATEFUL_SANDBOX_IMAGE= # optional; default public TCR in code if unset -# TCR_IMAGE= # from pnpm setup:tcr; used if STATEFUL_SANDBOX_IMAGE unset - -# --- Optional features --- -# TCB_PROVISION_MODE=shared -# GIT_ARCHIVE_REPO= / GIT_ARCHIVE_TOKEN= / GIT_ARCHIVE_USER= -# Miniprogram deploy via 沙箱业务镜像 is enabled by default (no env flag). -# ASK_USER_BASE_URL=http://127.0.0.1:3001 diff --git a/packages/server/package.json b/packages/server/package.json index d0a2a88..cba397f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -12,7 +12,7 @@ "./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", diff --git a/packages/server/scripts/debug-start-instance.ts b/packages/server/scripts/debug-start-instance.ts index 3d23668..b2ac5d2 100644 --- a/packages/server/scripts/debug-start-instance.ts +++ b/packages/server/scripts/debug-start-instance.ts @@ -14,7 +14,7 @@ import { } from '../src/sandbox/stateful-custom-configuration.js' const here = dirname(fileURLToPath(import.meta.url)) -config({ path: resolve(here, '../.env') }) +config({ path: resolve(here, '../../../.env.local') }) async function callAgs(action: string, param: Record<string, unknown>) { const managerModule = await import('@cloudbase/manager-node') @@ -29,8 +29,8 @@ async function callAgs(action: string, param: Record<string, unknown>) { ver: string, ) => { request: (a: string, p: object) => Promise<unknown> } - const secretId = process.env.TCB_SECRET_ID || process.env.TENCENTCLOUD_SECRET_ID || '' - const secretKey = process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_SECRET_KEY || '' + const secretId = process.env.TCB_SECRET_ID || '' + const secretKey = process.env.TCB_SECRET_KEY || '' const envId = process.env.TCB_ENV_ID || '' const app = new CloudBase({ secretId, secretKey, envId }) const ags = new CloudService(app.context, 'ags', '2025-09-20') diff --git a/packages/server/scripts/describe-stateful-tool.ts b/packages/server/scripts/describe-stateful-tool.ts index 4dfb04a..96681b6 100644 --- a/packages/server/scripts/describe-stateful-tool.ts +++ b/packages/server/scripts/describe-stateful-tool.ts @@ -9,7 +9,7 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) -config({ path: resolve(here, '../.env') }) +config({ path: resolve(here, '../../../.env.local') }) async function callAgs(action: string, param: Record<string, unknown>) { const managerModule = await import('@cloudbase/manager-node') @@ -24,8 +24,8 @@ async function callAgs(action: string, param: Record<string, unknown>) { ver: string, ) => { request: (a: string, p: object) => Promise<Record<string, unknown>> } - const secretId = process.env.TCB_SECRET_ID || process.env.TENCENTCLOUD_SECRET_ID || '' - const secretKey = process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_SECRET_KEY || '' + const secretId = process.env.TCB_SECRET_ID || '' + const secretKey = process.env.TCB_SECRET_KEY || '' const envId = process.env.TCB_ENV_ID || '' if (!secretId || !secretKey || !envId) throw new Error('TCB_ENV_ID / TCB_SECRET_ID / TCB_SECRET_KEY required') diff --git a/packages/server/scripts/probe-gateway-port.ts b/packages/server/scripts/probe-gateway-port.ts index 169265e..758aa19 100644 --- a/packages/server/scripts/probe-gateway-port.ts +++ b/packages/server/scripts/probe-gateway-port.ts @@ -16,7 +16,7 @@ import { } from '../src/sandbox/stateful/gateway.js' const here = dirname(fileURLToPath(import.meta.url)) -config({ path: resolve(here, '../.env') }) +config({ path: resolve(here, '../../../.env.local') }) async function main() { const sandboxId = process.env.SANDBOX_ID || process.env.STATEFUL_SANDBOX_ID || '' diff --git a/packages/server/scripts/stop-stateful-instances.ts b/packages/server/scripts/stop-stateful-instances.ts index d18e155..5b1e8eb 100644 --- a/packages/server/scripts/stop-stateful-instances.ts +++ b/packages/server/scripts/stop-stateful-instances.ts @@ -1,6 +1,6 @@ /** * Stop active AGS sandbox instances for the configured stateful tool (shared-env cleanup). - * Loads packages/server/.env like verify-stateful-e2e.ts. + * Loads repo root .env.local (see scripts/lib/env-files.mjs). * * Usage: pnpm stop:stateful-instances */ @@ -9,7 +9,7 @@ import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = dirname(fileURLToPath(import.meta.url)) -const envPath = resolve(__dirname, '../.env') +const envPath = resolve(__dirname, '../../../.env.local') if (existsSync(envPath)) { for (const line of readFileSync(envPath, 'utf8').split('\n')) { const t = line.trim() @@ -33,9 +33,8 @@ async function callAgsManagerApi(action: string, param: Record<string, unknown>) (managerUtilsModule as any).default?.CloudService) as any const secretId = - process.env.TCB_SECRET_ID || process.env.TENCENTCLOUD_SECRET_ID || process.env.TENCENT_SECRET_ID || '' - const secretKey = - process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_SECRET_KEY || process.env.TENCENT_SECRET_KEY || '' + process.env.TCB_SECRET_ID || '' + const secretKey = process.env.TCB_SECRET_KEY || '' const token = process.env.TCB_TOKEN || process.env.TENCENTCLOUD_SESSIONTOKEN || '' const managerEnvId = process.env.TCB_ENV_ID || '' @@ -54,7 +53,7 @@ async function resolveToolId(): Promise<string> { const envId = process.env.TCB_ENV_ID || '' if (!envId) { - throw new Error('Set STATEFUL_TOOL_ID or TCB_ENV_ID in packages/server/.env') + throw new Error('Set STATEFUL_TOOL_ID or TCB_ENV_ID in .env.local') } const { statefulToolNameForEnv } = await import('../src/sandbox/ensure-stateful-tool.js') 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 635b4d4..c699055 100644 --- a/packages/server/scripts/test-opencode-coding-preview-e2e.mts +++ b/packages/server/scripts/test-opencode-coding-preview-e2e.mts @@ -2,7 +2,7 @@ /** * 【过时】上游 main 保留的 SCF 环境 e2e,feature/stateful-infra 分支请勿运行。 * 依赖 `scf-sandbox-manager`,仅适用于 SCF 沙箱;AGS + 沙箱业务镜像请用 verify-stateful-e2e.ts。 - * Stateful: `npx tsx --env-file=.env scripts/verify-stateful-e2e.ts` + * Stateful: `npx tsx scripts/verify-stateful-e2e.ts` (loads ../../.env.local) * * E2E: OpenCode runtime + coding mode + preview + 第二轮修改 * @@ -14,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 a3db141..c500d5b 100644 --- a/packages/server/scripts/test-opencode-sandbox-e2e.mts +++ b/packages/server/scripts/test-opencode-sandbox-e2e.mts @@ -2,7 +2,7 @@ /** * 【过时】上游 main 保留的 SCF 环境 e2e,feature/stateful-infra 分支请勿运行。 * 依赖 `scf-sandbox-manager`,仅适用于 SCF 沙箱;AGS + 沙箱业务镜像请用 verify-stateful-e2e.ts。 - * Stateful: `npx tsx --env-file=.env scripts/verify-stateful-e2e.ts` + * Stateful: `npx tsx scripts/verify-stateful-e2e.ts` (loads ../../.env.local) * * 沙箱隔离 e2e 测试(新架构 - tool override + env 注入) * @@ -18,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 index b8bcd0b..4e64109 100644 --- a/packages/server/scripts/update-stateful-tool-image.ts +++ b/packages/server/scripts/update-stateful-tool-image.ts @@ -1,7 +1,7 @@ /** * Point an existing stateful SDT at a new container image (after 沙箱业务镜像 rebuild). * - * Usage (from packages/server, with .env loaded): + * 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 */ @@ -11,7 +11,7 @@ import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) -config({ path: resolve(here, '../.env') }) +config({ path: resolve(here, '../../../.env.local') }) async function callAgs(action: string, param: Record<string, unknown>) { const managerModule = await import('@cloudbase/manager-node') @@ -26,8 +26,8 @@ async function callAgs(action: string, param: Record<string, unknown>) { ver: string, ) => { request: (a: string, p: object) => Promise<unknown> } - const secretId = process.env.TCB_SECRET_ID || process.env.TENCENTCLOUD_SECRET_ID || '' - const secretKey = process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_SECRET_KEY || '' + const secretId = process.env.TCB_SECRET_ID || '' + const secretKey = process.env.TCB_SECRET_KEY || '' const envId = process.env.TCB_ENV_ID || '' if (!secretId || !secretKey || !envId) { throw new Error('TCB_ENV_ID / TCB_SECRET_ID / TCB_SECRET_KEY required') diff --git a/packages/server/scripts/update-stateful-tool-ports.ts b/packages/server/scripts/update-stateful-tool-ports.ts index f204d67..28da07e 100644 --- a/packages/server/scripts/update-stateful-tool-ports.ts +++ b/packages/server/scripts/update-stateful-tool-ports.ts @@ -9,7 +9,7 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) -config({ path: resolve(here, '../.env') }) +config({ path: resolve(here, '../../../.env.local') }) /** Match ensure-stateful-tool.ts — preview via :9000, envd for e2b SDK. */ const STANDARD_TOOL_PORTS = [ @@ -30,8 +30,8 @@ async function callAgs(action: string, param: Record<string, unknown>) { ver: string, ) => { request: (a: string, p: object) => Promise<unknown> } - const secretId = process.env.TCB_SECRET_ID || process.env.TENCENTCLOUD_SECRET_ID || '' - const secretKey = process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_SECRET_KEY || '' + const secretId = process.env.TCB_SECRET_ID || '' + const secretKey = process.env.TCB_SECRET_KEY || '' const envId = process.env.TCB_ENV_ID || '' if (!secretId || !secretKey || !envId) throw new Error('TCB_ENV_ID / TCB_SECRET_ID / TCB_SECRET_KEY required') diff --git a/packages/server/scripts/verify-stateful-e2e.ts b/packages/server/scripts/verify-stateful-e2e.ts index 6820852..2c441b1 100644 --- a/packages/server/scripts/verify-stateful-e2e.ts +++ b/packages/server/scripts/verify-stateful-e2e.ts @@ -10,7 +10,7 @@ import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = dirname(fileURLToPath(import.meta.url)) -const envPath = resolve(__dirname, '../.env') +const envPath = resolve(__dirname, '../../../.env.local') if (existsSync(envPath)) { for (const line of readFileSync(envPath, 'utf8').split('\n')) { const t = line.trim() @@ -100,8 +100,8 @@ async function main() { const session = await provider.prepare(inst, { credentials: { envId: process.env.TCB_ENV_ID || '', - secretId: process.env.TCB_SECRET_ID || process.env.TENCENTCLOUD_SECRET_ID || '', - secretKey: process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_SECRET_KEY || '', + secretId: process.env.TCB_SECRET_ID || '', + secretKey: process.env.TCB_SECRET_KEY || '', }, codingMode: true, backendOptions: { backend: 'stateful' }, diff --git a/packages/server/src/lib/sandbox-config.ts b/packages/server/src/lib/sandbox-config.ts index b43bdb8..9f68c0e 100644 --- a/packages/server/src/lib/sandbox-config.ts +++ b/packages/server/src/lib/sandbox-config.ts @@ -72,7 +72,7 @@ export async function resolveSandboxInstanceMode(): Promise<{ source: SandboxInstanceModeSource envDefault: SandboxInstanceMode }> { - const envIsolation = process.env.WORKSPACE_ISOLATION || process.env.SANDBOX_INSTANCE_MODE || '' + const envIsolation = process.env.WORKSPACE_ISOLATION || '' const envDefault = normalizeSandboxMode(envIsolation || BUILTIN_DEFAULT) try { diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index e87cfea..3fefe4a 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -181,7 +181,7 @@ auth.post('/register', async (c) => { ) } if (msg.includes('Missing JWE secret')) { - return c.json({ error: 'Server missing JWE_SECRET in packages/server/.env' }, 500) + return c.json({ error: 'Server missing JWE_SECRET in .env.local' }, 500) } return c.json( { diff --git a/packages/server/src/sandbox/ensure-stateful-tool.ts b/packages/server/src/sandbox/ensure-stateful-tool.ts index af11bed..db414ee 100644 --- a/packages/server/src/sandbox/ensure-stateful-tool.ts +++ b/packages/server/src/sandbox/ensure-stateful-tool.ts @@ -78,10 +78,8 @@ async function callAgsManagerApi(action: string, param: Record<string, unknown>) const CloudService = ((managerUtilsModule as any).CloudService || (managerUtilsModule as any).default?.CloudService) as any - const secretId = - process.env.TCB_SECRET_ID || process.env.TENCENTCLOUD_SECRET_ID || process.env.TENCENT_SECRET_ID || '' - const secretKey = - process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_SECRET_KEY || process.env.TENCENT_SECRET_KEY || '' + const secretId = process.env.TCB_SECRET_ID || '' + const secretKey = process.env.TCB_SECRET_KEY || '' const token = process.env.TCB_TOKEN || process.env.TENCENTCLOUD_SESSIONTOKEN || '' const managerEnvId = process.env.TCB_ENV_ID || '' diff --git a/packages/server/src/sandbox/provider/stateful-provider.ts b/packages/server/src/sandbox/provider/stateful-provider.ts index 154c194..29b13a4 100644 --- a/packages/server/src/sandbox/provider/stateful-provider.ts +++ b/packages/server/src/sandbox/provider/stateful-provider.ts @@ -80,10 +80,8 @@ function readStatefulRuntimeConfig(envId: string, toolId: string): StatefulRunti const accessToken = getTcbAccessToken() const sandboxBaseUrl = resolveStatefulGatewayUrl(envId) const preCreatedSandboxId = process.env.STATEFUL_SANDBOX_ID || '' - const managerSecretId = - process.env.TCB_SECRET_ID || process.env.TENCENTCLOUD_SECRET_ID || process.env.TENCENT_SECRET_ID || '' - const managerSecretKey = - process.env.TCB_SECRET_KEY || process.env.TENCENTCLOUD_SECRET_KEY || process.env.TENCENT_SECRET_KEY || '' + 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 diff --git a/packages/web/src/pages/admin/settings-page.tsx b/packages/web/src/pages/admin/settings-page.tsx index 09372b8..e325968 100644 --- a/packages/web/src/pages/admin/settings-page.tsx +++ b/packages/web/src/pages/admin/settings-page.tsx @@ -29,7 +29,7 @@ const PROVISION_MODES = [ }, ] as const -const SANDBOX_INSTANCE_MODES = [ +const WORKSPACE_ISOLATION_MODES = [ { value: 'shared', label: '共享实例', @@ -268,7 +268,7 @@ export function AdminSettingsPage() { </div> <RadioGroup value={sandboxInstanceMode} onValueChange={setSandboxInstanceMode} className="space-y-3"> - {SANDBOX_INSTANCE_MODES.map((mode) => ( + {WORKSPACE_ISOLATION_MODES.map((mode) => ( <label key={mode.value} className={`flex items-start gap-3 rounded-lg border p-4 cursor-pointer transition-colors ${ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ce7a2c..c037d74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: '@eslint/eslintrc': specifier: ^3.3.3 version: 3.3.5 + '@inquirer/password': + specifier: ^4.0.23 + version: 4.0.23(@types/node@20.19.39) '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.2.2 @@ -1467,6 +1470,41 @@ packages: cpu: [x64] os: [win32] + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@isaacs/cliui@9.0.0': resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} @@ -3029,6 +3067,10 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} @@ -4990,6 +5032,10 @@ packages: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -6388,6 +6434,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + zip-stream@2.1.3: resolution: {integrity: sha512-EkXc2JGcKhO5N5aZ7TmuNo45budRaFGHOmz24wtJR7znbNqDPmdZtUauKX6et8KAVseAMBOyWJqEpXcHTBsh7Q==} engines: {node: '>= 6'} @@ -7351,6 +7401,35 @@ snapshots: '@img/sharp-win32-x64@0.33.5': optional: true + '@inquirer/ansi@1.0.2': {} + + '@inquirer/core@10.3.2(@types/node@20.19.39)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.39) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/password@4.0.23(@types/node@20.19.39)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/type': 3.0.10(@types/node@20.19.39) + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/type@3.0.10(@types/node@20.19.39)': + optionalDependencies: + '@types/node': 20.19.39 + '@isaacs/cliui@9.0.0': {} '@isaacs/fs-minipass@4.0.1': @@ -8967,6 +9046,8 @@ snapshots: cli-spinners@2.9.2: {} + cli-width@4.1.0: {} + cliui@6.0.0: dependencies: string-width: 4.2.3 @@ -11226,6 +11307,8 @@ snapshots: mustache@4.2.0: {} + mute-stream@2.0.0: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -12725,6 +12808,8 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors-cjs@2.1.3: {} + zip-stream@2.1.3: dependencies: archiver-utils: 2.1.0 diff --git a/scripts/codebuddy-setup.mjs b/scripts/codebuddy-setup.mjs index 773484f..9a77e62 100644 --- a/scripts/codebuddy-setup.mjs +++ b/scripts/codebuddy-setup.mjs @@ -4,13 +4,13 @@ * CodeBuddy 模型配置引导脚本 * * 作用: - * - 从 CloudBase 拉取可用 AI 模型列表(DescribeAIModels) + * - 从当前 CloudBase 环境(AI+ / DescribeAIModels)拉取已开通模型列表 * - 生成 packages/server/.config/.codebuddy/models.json - * - 供 @tencent-ai/agent-sdk 读取自定义模型列表 + * - 供 @tencent-ai/agent-sdk 读取;模型调用走 CLOUDBASE_API_KEY(非 CODEBUDDY_API_KEY) * * 设计约束: * - 只处理项目级配置 - * - 凭证只存 packages/server/.env + * - 凭证只存根目录 .env.local * - 模板中的 ${VAR_NAME} 占位符在运行时被解析为环境变量值 * * 用法: @@ -19,7 +19,6 @@ import fs from 'node:fs' import path from 'node:path' -import readline from 'node:readline' import { createRequire } from 'node:module' const require = createRequire(import.meta.url) @@ -29,7 +28,9 @@ let managerApp = null // ─── Constants ─────────────────────────────────────────────────────────── const ROOT = process.cwd() -const SERVER_ENV_FILE = path.join(ROOT, 'packages', 'server', '.env') +const ENV_TARGET_FILE = process.env.OVC_ENV_FILE + ? path.resolve(process.env.OVC_ENV_FILE) + : path.join(ROOT, '.env.local') const MODELS_CONFIG_DIR = path.join(ROOT, 'packages', 'server', '.config', '.codebuddy') const MODELS_CONFIG_FILE = path.join(MODELS_CONFIG_DIR, 'models.json') @@ -61,74 +62,6 @@ function logSection(title) { console.log(`${colors.bold}${colors.cyan}━━━ ${title} ━━━${colors.reset}`) } -let _rl = null - -function drainStdin() { - return new Promise((resolve) => { - if (!process.stdin.readable) return resolve() - process.stdin.resume() - const drain = () => { - while (process.stdin.read() !== null) { - /* discard */ - } - } - drain() - setTimeout(() => { - drain() - process.stdin.pause() - resolve() - }, 10) - }) -} - -async function prompt(question, { hidden = false, defaultValue = '' } = {}) { - if (hidden) { - if (_rl) { - _rl.close() - _rl = null - } - await drainStdin() - process.stdout.write(`${question}: `) - process.stdin.setRawMode(true) - process.stdin.resume() - return new Promise((resolve) => { - let buf = '' - const onData = (chunk) => { - const c = chunk.toString('utf8') - if (c === '\n' || c === '\r' || c === '\u0004') { - process.stdin.setRawMode(false) - process.stdin.pause() - process.stdin.removeListener('data', onData) - process.stdout.write('\n') - resolve(buf || defaultValue) - } else if (c === '\u0003') { - process.exit(130) - } else if (c.charCodeAt(0) === 127) { - buf = buf.slice(0, -1) - } else { - buf += c - } - } - process.stdin.on('data', onData) - }) - } - if (_rl) { - _rl.close() - _rl = null - } - await drainStdin() - _rl = readline.createInterface({ input: process.stdin, output: process.stdout }) - const hint = defaultValue ? ` ${colors.dim}[${defaultValue}]${colors.reset}` : '' - return new Promise((resolve) => { - _rl.question(`${question}${hint}: `, (answer) => { - _rl.close() - _rl = null - const value = answer.trim() - resolve(value || defaultValue) - }) - }) -} - // ─── Env file helpers ─────────────────────────────────────────────────── function parseEnvFile(file) { @@ -274,46 +207,33 @@ function writeModelsConfig(config) { // ─── Main ──────────────────────────────────────────────────────────────── async function main() { - logSection('CodeBuddy 模型配置') + const envLabel = path.basename(ENV_TARGET_FILE) + logSection('CodeBuddy 自定义模型(数据源:CloudBase AI+)') + console.log('') + console.log(` ${colors.dim}从当前 TCB 环境读取已开通模型 → models.json;与 CODEBUDDY_API_KEY(Copilot)无关。${colors.reset}`) + console.log(` ${colors.dim}env:${envLabel}${colors.reset}`) + console.log('') - const envNow = parseEnvFile(SERVER_ENV_FILE) + const envNow = parseEnvFile(ENV_TARGET_FILE) const envId = envNow['TCB_ENV_ID'] const secretId = envNow['TCB_SECRET_ID'] const secretKey = envNow['TCB_SECRET_KEY'] const apiKey = envNow['CLOUDBASE_API_KEY'] if (!envId || !secretId || !secretKey) { - log('缺少 CloudBase 凭证,请确保 packages/server/.env 中包含 TCB_ENV_ID、TCB_SECRET_ID、TCB_SECRET_KEY', 'err') + log(`缺少 CloudBase 凭证,请确保 ${envLabel} 含 TCB_ENV_ID、TCB_SECRET_ID、TCB_SECRET_KEY`, 'err') process.exit(1) } - // 1. 检查 / 引导输入 CLOUDBASE_API_KEY - if (!apiKey) { - log('缺少 CLOUDBASE_API_KEY,请确保 packages/server/.env 中已配置', 'warn') - console.log(` 可从 https://tcb.cloud.tencent.com/dev?envId=${envId}#/env/apikey 创建获取`) - console.log('') - const value = await prompt(' CLOUDBASE_API_KEY', { hidden: true }) - if (value && value.trim() !== '') { - const { added, updated } = upsertEnvFile(SERVER_ENV_FILE, { - CLOUDBASE_API_KEY: value.trim(), - CODEBUDDY_USE_CUSTOM_MODELS: 'true', - }) - if (added.includes('CLOUDBASE_API_KEY')) log('已追加 CLOUDBASE_API_KEY 到 packages/server/.env', 'ok') - if (updated.includes('CLOUDBASE_API_KEY')) log('已更新 packages/server/.env 中的 CLOUDBASE_API_KEY', 'ok') - if (added.includes('CODEBUDDY_USE_CUSTOM_MODELS')) log('已追加 CODEBUDDY_USE_CUSTOM_MODELS=true 到 packages/server/.env', 'ok') - if (updated.includes('CODEBUDDY_USE_CUSTOM_MODELS')) log('已更新 packages/server/.env 中的 CODEBUDDY_USE_CUSTOM_MODELS', 'ok') - envNow['CLOUDBASE_API_KEY'] = value.trim() - envNow['CODEBUDDY_USE_CUSTOM_MODELS'] = 'true' - } else { - log('未输入 CLOUDBASE_API_KEY,跳过', 'warn') - } + if (!apiKey?.trim()) { + log(`缺少 CLOUDBASE_API_KEY,请先 ./init.sh 完成「CloudBase AI API Key」步骤,或写入 ${envLabel}`, 'err') + console.log(` 创建:https://tcb.cloud.tencent.com/dev?envId=${envId}#/env/apikey`) + process.exit(1) } - // 2. 拉取 CloudBase AI 模型列表 - log('拉取 CloudBase AI 模型列表...', 'step') + log('拉取当前环境的 AI 模型列表(DescribeAIModels)...', 'step') const modelList = await describeAIModes(envId, secretId, secretKey) - // 3. 构建 CodeBuddy 模型配置 const newConfig = buildCodeBuddyModelsConfig(modelList, envId) if (newConfig.models.length === 0) { @@ -344,7 +264,6 @@ async function main() { } } - // 提示已从 CloudBase 移除的模型 const removedCloudbaseModels = existingConfig.models.filter( (m) => !newModelIds.has(m.id) && m.vendor === 'cloudbase', ) diff --git a/scripts/deploy.mjs b/scripts/deploy.mjs index 67b0c5a..32626a1 100755 --- a/scripts/deploy.mjs +++ b/scripts/deploy.mjs @@ -1,37 +1,31 @@ #!/usr/bin/env node /** - * Deploy Script — 一键部署到 CloudBase 云托管 + * Deploy to CloudBase CloudRun (container). * - * Usage: - * pnpm deploy:cloud # 部署到云托管 - * pnpm deploy:cloud --skip-build # 跳过本地构建步骤(云端会重新构建) + * Env layout (repo root): + * .env.local — local dev only (pnpm dev) + * .env.cloud — cloud: CLI credentials + runtime vars synced after deploy + * .env.example — documentation only * - * TODO: 云函数(镜像模式)部署暂未完成,存在以下问题: - * - CLI 无法正确传递 ImagePort 参数 - * - 平台默认 ImagePort=9000,需要镜像内 ENV PORT=9000 匹配 - * - 镜像冷启动时间过长(1.29GB),容易超过 InitTimeout - * 待平台侧修复后可重新启用 + * Usage: + * pnpm deploy:cloud + * pnpm deploy:cloud --skip-env-sync # deploy only, do not call UpdateCloudRunServer */ import { execSync } from 'child_process' import { createRequire } from 'module' import { existsSync, readFileSync, writeFileSync } from 'fs' import { resolve } from 'path' +import { ENV_CLOUD, loadEnvFile, cloudRuntimeEnvFromFile } from './lib/env-files.mjs' const require = createRequire(import.meta.url) const CloudBase = require('@cloudbase/manager-node') -// ===================== Constants ===================== - const ROOT = process.cwd() -const ENV_FILE = resolve(ROOT, '.env.local') -const SERVER_ENV_FILE = resolve(ROOT, 'packages/server/.env') const CLOUDBASERC = resolve(ROOT, 'cloudbaserc.json') const DEFAULT_SERVICE_NAME = 'vibecoding-platform' -// ===================== Helpers ===================== - const colors = { reset: '\x1b[0m', bright: '\x1b[1m', @@ -57,20 +51,6 @@ function logSection(title) { console.log(`${colors.bright}${colors.cyan}━━━ ${title} ━━━${colors.reset}`) } -function loadEnvFile(filePath) { - const env = {} - if (existsSync(filePath)) { - readFileSync(filePath, 'utf-8').split('\n').forEach((line) => { - const trimmed = line.trim() - if (trimmed && !trimmed.startsWith('#')) { - const [key, ...rest] = trimmed.split('=') - if (key) env[key.trim()] = rest.join('=').trim() - } - }) - } - return env -} - function commandExists(name) { try { execSync(`which ${name}`, { stdio: 'pipe' }) @@ -85,8 +65,6 @@ function run(cmd, options = {}) { execSync(cmd, { stdio: 'inherit', cwd: ROOT, ...options }) } -// ===================== CloudBase SDK Helper ===================== - function createCloudBaseApp(env) { return new CloudBase({ secretId: env.TCB_SECRET_ID, @@ -95,53 +73,94 @@ function createCloudBaseApp(env) { }) } -// ===================== CloudRun Deploy ===================== +async function syncCloudRunEnv(app, envId, serverName, runtimeEnv) { + const keys = Object.keys(runtimeEnv) + if (keys.length === 0) { + log('.env.cloud 无有效变量,跳过云托管环境变量同步', 'warn') + return false + } -async function deployCloudRun(env) { + logSection('同步云托管环境变量') + log(`从 .env.cloud 写入 ${keys.length} 个变量到服务 ${serverName}`, 'info') + + const tcbr = app.commonService('tcbr') + await tcbr.call({ + Action: 'UpdateCloudRunServer', + Param: { + EnvId: envId, + ServerName: serverName, + Items: [ + { + Key: 'EnvParams', + Value: JSON.stringify(runtimeEnv), + }, + ], + }, + }) + log('云托管环境变量已更新(新实例生效)', 'success') + return true +} + +async function deployCloudRun(deployEnv, options) { logSection('部署到云托管(容器服务)') - const envId = env.TCB_ENV_ID + const envId = deployEnv.TCB_ENV_ID if (!envId) { log('缺少 TCB_ENV_ID,请先运行 ./init.sh', 'error') process.exit(1) } if (!commandExists('cloudbase')) { - log('cloudbase CLI 未安装,请先安装:npm i -g @cloudbase/cli', 'error') + log('cloudbase CLI 未安装:npm i -g @cloudbase/cli', 'error') process.exit(1) } - // Ensure cloudbaserc.json has envId so CLI can read it const rcBackup = existsSync(CLOUDBASERC) ? readFileSync(CLOUDBASERC, 'utf-8') : null - const rcContent = { envId } - writeFileSync(CLOUDBASERC, JSON.stringify(rcContent, null, 2)) + writeFileSync(CLOUDBASERC, JSON.stringify({ envId }, null, 2)) try { - // cloudbase cloudrun deploy uploads source + Dockerfile to cloud for building - // No local Docker required — cloud builds the image from Dockerfile log('提交到云托管(云端构建)...') run(`cloudbase cloudrun deploy -s ${DEFAULT_SERVICE_NAME} --port 80 --force --source .`) - } catch (err) { + } catch { log('部署失败', 'error') - log(`可在控制台手动部署:https://tcb.cloud.tencent.com/dev?envId=${envId}#/run`, 'info') + log(`控制台:https://tcb.cloud.tencent.com/dev?envId=${envId}#/run`, 'info') process.exit(1) } finally { if (rcBackup) writeFileSync(CLOUDBASERC, rcBackup) } - // Query service domain via CloudBase manager-node SDK let accessUrl = '' + const app = createCloudBaseApp(deployEnv) try { - const app = createCloudBaseApp(env) const tcbr = app.commonService('tcbr') const result = await tcbr.call({ Action: 'DescribeCloudRunServerDetail', Param: { EnvId: envId, ServerName: DEFAULT_SERVICE_NAME }, }) accessUrl = result.BaseInfo?.DefaultDomainName || '' - } catch { /* ignore — URL is optional */ } + } catch { + /* optional */ + } + + if (!options.skipEnvSync) { + if (!existsSync(ENV_CLOUD)) { + log('未找到 .env.cloud,请运行 ./init.sh 生成或手动创建', 'warn') + } else { + const runtimeEnv = cloudRuntimeEnvFromFile(ENV_CLOUD) + if (accessUrl && !runtimeEnv.ASK_USER_BASE_URL) { + runtimeEnv.ASK_USER_BASE_URL = accessUrl.startsWith('http') + ? accessUrl + : `https://${accessUrl}` + } + try { + await syncCloudRunEnv(app, envId, DEFAULT_SERVICE_NAME, runtimeEnv) + } catch (err) { + log('环境变量 API 同步失败,请在控制台 → 云托管 → 服务配置 粘贴 .env.cloud', 'warn') + console.error(err) + } + } + } - // Done console.log('') log('部署已提交,云端构建中...', 'success') console.log('') @@ -150,33 +169,35 @@ async function deployCloudRun(env) { console.log(` ${colors.bright}访问地址:${colors.reset}${accessUrl}`) } console.log(` ${colors.bright}构建进度:${colors.reset}`) - console.log(` https://tcb.cloud.tencent.com/dev?envId=${envId}#/platform-run/service/detail?serverName=${DEFAULT_SERVICE_NAME}&tabId=deploy&envId=${envId}`) + console.log( + ` https://tcb.cloud.tencent.com/dev?envId=${envId}#/platform-run/service/detail?serverName=${DEFAULT_SERVICE_NAME}&tabId=deploy&envId=${envId}`, + ) console.log('') } -// ===================== Main ===================== - async function main() { console.log('') console.log(`${colors.bright}${colors.cyan}━━━ 部署到 CloudBase 云托管 ━━━${colors.reset}`) console.log('') const args = process.argv.slice(2) + const skipEnvSync = args.includes('--skip-env-sync') - // Load env - const env = { ...loadEnvFile(ENV_FILE), ...loadEnvFile(SERVER_ENV_FILE) } - - if (!env.TCB_ENV_ID) { - log('未找到 TCB_ENV_ID,请先运行 ./init.sh 完成初始化', 'error') + const deployEnv = loadEnvFile(ENV_CLOUD) + if (!existsSync(ENV_CLOUD)) { + log('未找到 .env.cloud,请运行 ./init.sh 并选择 2) .env.cloud', 'error') process.exit(1) } - - if (!env.TCB_SECRET_ID || !env.TCB_SECRET_KEY) { - log('未找到 TCB_SECRET_ID / TCB_SECRET_KEY,请先运行 ./init.sh 完成初始化', 'error') + if (!deployEnv.TCB_ENV_ID) { + log('.env.cloud 缺少 TCB_ENV_ID', 'error') + process.exit(1) + } + if (!deployEnv.TCB_SECRET_ID || !deployEnv.TCB_SECRET_KEY) { + log('.env.cloud 缺少 TCB_SECRET_ID / TCB_SECRET_KEY', 'error') process.exit(1) } - await deployCloudRun(env) + await deployCloudRun(deployEnv, { skipEnvSync }) } main().catch((err) => { diff --git a/scripts/init.mjs b/scripts/init.mjs index dd919d1..e92c264 100644 --- a/scripts/init.mjs +++ b/scripts/init.mjs @@ -16,12 +16,17 @@ import { existsSync, readFileSync, writeFileSync } from 'fs' import { resolve } from 'path' import { homedir } from 'os' import crypto from 'crypto' -import readline from 'readline' +import { + ENV_LOCAL, + ENV_CLOUD, + loadEnvFile, + saveEnvVar, +} from './lib/env-files.mjs' +import { closeReadline, promptInput } from './lib/prompt.mjs' // ===================== Constants ===================== const MIN_NODE_VERSION = 18 -const ENV_FILE = resolve(process.cwd(), '.env.local') const CLOUDBASE_AUTH_FILE = resolve(homedir(), '.config/.cloudbase/auth.json') const IS_WINDOWS = process.platform === 'win32' @@ -89,79 +94,6 @@ function runCommandSafe(cmd) { } } -// Shared readline state -let _rl = null - -// Drain any leftover data in stdin buffer -function drainStdin() { - return new Promise((resolve) => { - if (process.stdin.readable) { - process.stdin.resume() - const drain = () => { - while (process.stdin.read() !== null) { /* discard */ } - } - drain() - // Give a tick for any pending data - setTimeout(() => { - drain() - process.stdin.pause() - resolve() - }, 10) - } else { - resolve() - } - }) -} - -async function promptInput(prompt, hidden = false) { - return new Promise(async (resolve) => { - if (hidden) { - // Close shared rl temporarily for raw mode - if (_rl) { _rl.close(); _rl = null } - await drainStdin() - process.stdout.write(`${prompt}: `) - process.stdin.setRawMode(true) - process.stdin.resume() - let password = '' - const onData = (char) => { - const c = char.toString('utf8') - switch (c) { - case '\n': - case '\r': - case '\u0004': - process.stdin.setRawMode(false) - process.stdin.pause() - process.stdin.removeListener('data', onData) - process.stdout.write('\n') - resolve(password) - break - case '\u0003': - process.exit() - break - default: - if (c.charCodeAt(0) === 127) { - password = password.slice(0, -1) - } else { - password += c - } - break - } - } - process.stdin.on('data', onData) - } else { - // Close any existing rl to reset state - if (_rl) { _rl.close(); _rl = null } - await drainStdin() - _rl = readline.createInterface({ input: process.stdin, output: process.stdout }) - _rl.question(`${prompt}: `, (answer) => { - _rl.close() - _rl = null - resolve(answer.trim()) - }) - } - }) -} - async function askYesNo(prompt, defaultValue = false) { const hint = defaultValue ? '[Y/n]' : '[y/N]' const answer = await promptInput(`${prompt} ${hint}`) @@ -266,72 +198,66 @@ function checkDocker() { } } -// ===================== TCR Setup ===================== - -function loadEnvFile() { - const env = {} - if (existsSync(ENV_FILE)) { - const content = readFileSync(ENV_FILE, 'utf-8') - content.split('\n').forEach((line) => { - const trimmed = line.trim() - if (trimmed && !trimmed.startsWith('#')) { - const [key, ...valueParts] = trimmed.split('=') - if (key) { - env[key.trim()] = valueParts.join('=').trim() - } - } +// ===================== TCR Setup (optional; disabled in Stateful default init) ===================== + +async function setupTcr() { + logSection('配置 TCR(容器镜像服务)') + + const env = loadEnvFile() + + log('正在运行 TCR 配置脚本...') + try { + execSync('node scripts/setup-tcr.mjs', { + stdio: 'inherit', + env: { + ...process.env, + TCB_SECRET_ID: tcbConfig.secretId || process.env.TCB_SECRET_ID || '', + TCB_SECRET_KEY: tcbConfig.secretKey || process.env.TCB_SECRET_KEY || '', + TCB_TOKEN: tcbConfig.token || process.env.TCB_TOKEN || '', + TCB_ENV_ID: tcbConfig.envId || process.env.TCB_ENV_ID || '', + TCB_REGION: process.env.TCB_REGION || 'ap-shanghai', + TENCENTCLOUD_ACCOUNT_ID: process.env.TENCENTCLOUD_ACCOUNT_ID || '', + TCR_PASSWORD: env['TCR_PASSWORD'] || '', + }, }) + log('TCR 配置完成', 'success') + return true + } catch (error) { + log('TCR 配置失败,可稍后手动执行。', 'warn') + log('运行:node scripts/setup-tcr.mjs', 'info') + return false } - return env } -function saveServerEnvVar(key, value) { - const serverEnvFile = resolve(process.cwd(), 'packages/server/.env') - const env = {} - if (existsSync(serverEnvFile)) { - readFileSync(serverEnvFile, 'utf-8').split('\n').forEach((line) => { - const trimmed = line.trim() - if (trimmed && !trimmed.startsWith('#')) { - const [k, ...v] = trimmed.split('=') - if (k) env[k.trim()] = v.join('=').trim() - } - }) - } +/** Set in main() before CloudBase setup: ENV_LOCAL or ENV_CLOUD */ +let envWriteTarget = ENV_LOCAL - if (env[key]) { - const content = readFileSync(serverEnvFile, 'utf-8') - const lines = content.split('\n') - const newLines = lines.map((line) => { - if (line.trim().startsWith(`${key}=`)) { - return `${key}=${value}` - } - return line - }) - writeFileSync(serverEnvFile, newLines.join('\n')) - } else { - const newline = Object.keys(env).length > 0 ? '\n' : '' - const content = existsSync(serverEnvFile) ? readFileSync(serverEnvFile, 'utf-8') : '' - writeFileSync(serverEnvFile, `${content}${newline}${key}=${value}`) - } +function saveTargetEnvVar(key, value) { + saveEnvVar(envWriteTarget, key, value) } -function saveEnvVar(key, value) { - const env = loadEnvFile() +async function promptEnvGenerationTarget() { + logSection('选择环境配置文件') + console.log('') + console.log(' 每次 init 只生成一个文件;本地与云端请各运行一次。') + console.log('') + console.log(' 1) .env.local — 本地开发 (pnpm dev)') + console.log(' 2) .env.cloud — 云托管运行时 (pnpm deploy:cloud)') + console.log('') - if (env[key]) { - const content = readFileSync(ENV_FILE, 'utf-8') - const lines = content.split('\n') - const newLines = lines.map((line) => { - if (line.trim().startsWith(`${key}=`)) { - return `${key}=${value}` - } - return line - }) - writeFileSync(ENV_FILE, newLines.join('\n')) - } else { - const newline = env && Object.keys(env).length > 0 ? '\n' : '' - const content = existsSync(ENV_FILE) ? readFileSync(ENV_FILE, 'utf-8') : '' - writeFileSync(ENV_FILE, `${content}${newline}${key}=${value}`) + while (true) { + const answer = await promptInput('请选择 1 或 2') + if (answer === '1') { + envWriteTarget = ENV_LOCAL + log('将生成 .env.local', 'success') + return ENV_LOCAL + } + if (answer === '2') { + envWriteTarget = ENV_CLOUD + log('将生成 .env.cloud', 'success') + return ENV_CLOUD + } + log('请输入 1 或 2', 'warn') } } @@ -403,7 +329,7 @@ async function runCloudbaseLogin() { }) } -// In-memory store for TCB credentials (not persisted to .env.local) +// In-memory store for TCB credentials (flushed in setupApplicationEnv to envWriteTarget) const tcbConfig = { secretId: '', secretKey: '', @@ -429,20 +355,7 @@ async function setupCloudbaseConfig() { const cliReady = await ensureCloudbaseInstalled() if (!cliReady) return false - const env = loadEnvFile() - - // Check server/.env for existing TCB config (already-configured state) - const serverEnvFile = resolve(process.cwd(), 'packages/server/.env') - const serverEnv = {} - if (existsSync(serverEnvFile)) { - readFileSync(serverEnvFile, 'utf-8').split('\n').forEach(line => { - const trimmed = line.trim() - if (trimmed && !trimmed.startsWith('#')) { - const [key, ...rest] = trimmed.split('=') - if (key) serverEnv[key.trim()] = rest.join('=').trim() - } - }) - } + const serverEnv = loadEnvFile(envWriteTarget) // ── 永久密钥询问 ────────────────────────────────────────────── const savedId = serverEnv['TCB_SECRET_ID'] || '' @@ -503,9 +416,9 @@ async function setupCloudbaseConfig() { tcbConfig.secretKey = secretKey // 立即写入文件,避免中断后需要重复输入 - saveServerEnvVar('TCB_SECRET_ID', secretId) - saveServerEnvVar('TCB_SECRET_KEY', secretKey) - log('密钥已写入 packages/server/.env', 'success') + saveTargetEnvVar('TCB_SECRET_ID', secretId) + saveTargetEnvVar('TCB_SECRET_KEY', secretKey) + log('密钥已写入目标 env 文件', 'success') // 使用永久密钥登录 cloudbase CLI log('正在使用永久密钥登录 cloudbase CLI...') @@ -626,17 +539,7 @@ async function setupCloudbaseConfig() { async function setupCodebuddy() { logSection('CodeBuddy 认证配置') - const serverEnvFile = resolve(process.cwd(), 'packages/server/.env') - const existingServerEnv = {} - if (existsSync(serverEnvFile)) { - readFileSync(serverEnvFile, 'utf-8').split('\n').forEach(line => { - const trimmed = line.trim() - if (trimmed && !trimmed.startsWith('#')) { - const [key, ...rest] = trimmed.split('=') - if (key) existingServerEnv[key.trim()] = rest.join('=').trim() - } - }) - } + const existingServerEnv = loadEnvFile(envWriteTarget) // Check if already configured const hasApiKey = !!existingServerEnv['CODEBUDDY_API_KEY'] @@ -699,7 +602,7 @@ async function setupCodebuddy() { console.log(` ${colors.bright}2) OAuth(企业旗舰版)${colors.reset}`) console.log(' 需要创建 OAuth 应用获取 Client ID / Secret。') console.log('') - console.log(` ${colors.dim}3) 跳过,稍后自行在 packages/server/.env 中配置${colors.reset}`) + console.log(` ${colors.dim}3) 跳过,稍后自行在 .env.local 中配置${colors.reset}`) console.log('') while (!codebuddyConfig.authMode) { @@ -709,7 +612,7 @@ async function setupCodebuddy() { } else if (choice === '2') { codebuddyConfig.authMode = 'oauth' } else if (choice === '3') { - log('已跳过,稍后请手动配置 packages/server/.env', 'info') + log('已跳过,稍后请手动配置 .env.local', 'info') return true } else { log('请输入 1、2 或 3', 'warn') @@ -792,18 +695,62 @@ async function setupCodebuddy() { return true } -async function setupCustomModel() { +function childProcessEnv() { + return { ...process.env, OVC_ENV_FILE: envWriteTarget } +} +/** CloudBase AI+ API Key — shared by CodeBuddy / OpenCode custom model setup (not CODEBUDDY_API_KEY). */ +async function ensureCloudbaseApiKey() { + const env = loadEnvFile(envWriteTarget) + if (env['CLOUDBASE_API_KEY']?.trim()) { + return true + } + + const envId = env['TCB_ENV_ID'] || tcbConfig.envId + logSection('CloudBase AI API Key(CLOUDBASE_API_KEY)') console.log('') - console.log(' 可选择配置以下自定义模型(从 CloudBase 拉取)。') + console.log(' 用途:拉取当前环境已开通的 AI 模型,写入 CodeBuddy / OpenCode 配置') + console.log(' 不是 CodeBuddy Copilot 登录密钥(那是 CODEBUDDY_API_KEY)') + console.log('') + if (envId) { + console.log(` 创建:${colors.cyan}https://tcb.cloud.tencent.com/dev?envId=${envId}#/env/apikey${colors.reset}`) + } console.log('') - // 1) CodeBuddy(默认启用) - const setupCodeBuddyModel = await askYesNo('是否配置 CodeBuddy 自定义模型 (默认启动)', true) + const value = await promptInput(' CLOUDBASE_API_KEY', true) + if (!value?.trim()) { + log('未输入 CLOUDBASE_API_KEY,将跳过自定义模型配置', 'warn') + return false + } + + saveTargetEnvVar('CLOUDBASE_API_KEY', value.trim()) + saveTargetEnvVar('CODEBUDDY_USE_CUSTOM_MODELS', 'true') + log('已写入 CLOUDBASE_API_KEY', 'success') + return true +} + +async function setupCustomModel() { + console.log('') + console.log(' 自定义模型:从 CloudBase 环境(AI+)拉取已开通模型列表,不是向 CodeBuddy 产品拉模型。') + console.log('') + + const setupCodeBuddyModel = await askYesNo('是否配置 CodeBuddy 自定义模型(models.json)', false) + const setupOpenCodeModel = await askYesNo( + '是否配置 OpenCode 自定义模型(opencode.json)', + envWriteTarget !== ENV_LOCAL, + ) + + if (setupCodeBuddyModel || setupOpenCodeModel) { + if (!(await ensureCloudbaseApiKey())) { + log('已跳过 CodeBuddy / OpenCode 模型配置', 'info') + return true + } + } + if (setupCodeBuddyModel) { log('正在运行 CodeBuddy 模型配置脚本...') try { - execSync('node scripts/codebuddy-setup.mjs', { stdio: 'inherit' }) + execSync('node scripts/codebuddy-setup.mjs', { stdio: 'inherit', env: childProcessEnv() }) log('CodeBuddy 模型配置完成', 'success') } catch (error) { log('CodeBuddy 模型配置失败,可稍后手动执行:node scripts/codebuddy-setup.mjs', 'warn') @@ -814,12 +761,10 @@ async function setupCustomModel() { log('已跳过 CodeBuddy 自定义模型配置,稍后请手动执行:node scripts/codebuddy-setup.mjs', 'info') } - // 2) OpenCode(默认启用) - const setupOpenCodeModel = await askYesNo('是否配置 OpenCode 自定义模型 (默认启动)', true) if (setupOpenCodeModel) { log('正在运行 OpenCode 模型配置脚本...') try { - execSync('node scripts/opencode-setup.mjs', { stdio: 'inherit' }) + execSync('node scripts/opencode-setup.mjs', { stdio: 'inherit', env: childProcessEnv() }) log('OpenCode 模型配置完成', 'success') } catch (error) { log('OpenCode 模型配置失败,可稍后手动执行:node scripts/opencode-setup.mjs', 'warn') @@ -833,96 +778,32 @@ async function setupCustomModel() { return true } -async function setupTcr() { - logSection('配置 TCR(容器镜像服务)') - - const env = loadEnvFile() +async function setupStatefulSandbox() { + logSection('Stateful 沙箱运行时') + console.log('') + console.log(' 需要 TCB_API_KEY(控制台 → 沙箱 API Key)。') + console.log('') - // Run the full TCR setup script, passing credentials via env - log('正在运行 TCR 配置脚本...') - try { - execSync('node scripts/setup-tcr.mjs', { - stdio: 'inherit', - env: { - ...process.env, - TCB_SECRET_ID: tcbConfig.secretId || process.env.TCB_SECRET_ID || '', - TCB_SECRET_KEY: tcbConfig.secretKey || process.env.TCB_SECRET_KEY || '', - TCB_TOKEN: tcbConfig.token || process.env.TCB_TOKEN || '', - TCB_ENV_ID: tcbConfig.envId || process.env.TCB_ENV_ID || '', - TCB_REGION: process.env.TCB_REGION || 'ap-shanghai', - TENCENTCLOUD_ACCOUNT_ID: process.env.TENCENTCLOUD_ACCOUNT_ID || '', - TCR_PASSWORD: env['TCR_PASSWORD'] || '', - }, - }) - log('TCR 配置完成', 'success') - return true - } catch (error) { - log('TCR 配置失败,可稍后手动执行。', 'warn') - log('运行:node scripts/setup-tcr.mjs', 'info') - return false + if (await askYesNo('是否现在填写 TCB_API_KEY?', true)) { + const apiKey = await promptInput(' TCB_API_KEY', true) + if (apiKey.trim()) { + saveTargetEnvVar('TCB_API_KEY', apiKey.trim()) + log('TCB_API_KEY 已写入目标 env 文件', 'success') + } } -} - -async function setupEnv() { - logSection('配置环境变量') - if (existsSync(ENV_FILE)) { - log('.env.local 已存在', 'success') - return true + if (await askYesNo('是否指定 STATEFUL_SANDBOX_IMAGE?(默认否,使用工程内置镜像)', false)) { + const image = await promptInput(' 镜像 URI') + if (image.trim()) { + saveTargetEnvVar('STATEFUL_SANDBOX_IMAGE', image.trim()) + log('STATEFUL_SANDBOX_IMAGE 已写入目标 env 文件', 'success') + } } - // Create minimal .env.local - const envContent = `# Environment variables -# Generated by init script - -# Session Encryption (auto-generated) -JWE_SECRET=${crypto.randomBytes(32).toString('base64')} -ENCRYPTION_KEY=${crypto.randomBytes(32).toString('hex')} - -# Auth Providers -NEXT_PUBLIC_AUTH_PROVIDERS=local - -# Sandbox instance mode: shared (one instance per env) | isolated (per task). Same env name as upstream main. -WORKSPACE_ISOLATION=shared - -# Rate Limiting -MAX_MESSAGES_PER_DAY=50 -MAX_SANDBOX_DURATION=300 -` - - writeFileSync(ENV_FILE, envContent) - log('已创建 .env.local(使用默认值)', 'success') return true } -// ===================== Server Environment ===================== - -async function setupServerEnv() { - logSection('配置服务端环境变量') - - const env = loadEnvFile() - const serverEnvFile = resolve(process.cwd(), 'packages/server/.env') - - // 读取已有的 server/.env(用于保留 CodeBuddy / Git Archive 等手动配置的值) - const existingServerEnv = {} - if (existsSync(serverEnvFile)) { - readFileSync(serverEnvFile, 'utf-8').split('\n').forEach(line => { - const trimmed = line.trim() - if (trimmed && !trimmed.startsWith('#')) { - const [key, ...rest] = trimmed.split('=') - if (key) existingServerEnv[key.trim()] = rest.join('=').trim() - } - }) - - const overwrite = await askYesNo('packages/server/.env 已存在,是否覆盖?(否则跳过此步骤)', true) - if (!overwrite) { - log('跳过服务端环境变量配置', 'info') - return true - } - } - - // TCB config from in-memory tcbConfig (collected during setupCloudbaseConfig) - // This avoids persisting TCB credentials to root .env.local +function createEnvResolvers(existingServerEnv, env) { const tcbKeyMap = { TCB_SECRET_ID: tcbConfig.secretId, TCB_SECRET_KEY: tcbConfig.secretKey, @@ -931,52 +812,55 @@ async function setupServerEnv() { TCB_REGION: process.env.TCB_REGION || 'ap-shanghai', TCB_PROVISION_MODE: tcbConfig.provisionMode, } - - // 常规 key:tcbConfig 内存值 > root .env.local > process.env > fallback - const get = (key, fallback = '') => (tcbKeyMap[key] !== undefined && tcbKeyMap[key] !== '') ? tcbKeyMap[key] : (env[key] || process.env[key] || fallback) - - // 保留型 key:优先读已有 server/.env,没有再用静态默认值 + const get = (key, fallback = '') => + tcbKeyMap[key] !== undefined && tcbKeyMap[key] !== '' + ? tcbKeyMap[key] + : env[key] || process.env[key] || fallback const getPreserved = (key, fallback = '') => existingServerEnv[key] || fallback + return { get, getPreserved } +} - const jweSecret = get('JWE_SECRET') - const encryptionKey = get('ENCRYPTION_KEY') +function buildSharedEnvBody(get, getPreserved, { port, nodeEnv, askUserBaseUrl }) { + const jweSecret = + get('JWE_SECRET') || crypto.randomBytes(32).toString('base64') + const encryptionKey = + get('ENCRYPTION_KEY') || crypto.randomBytes(32).toString('hex') - if (!jweSecret || !encryptionKey) { - log('.env.local 中缺少加密密钥', 'warn') - return false - } + const askUserBlock = + askUserBaseUrl === undefined + ? '' + : `\n# 云托管公网根 URL(本地 dev 用 http://127.0.0.1:3001;部署后填控制台域名)\nASK_USER_BASE_URL=${askUserBaseUrl}\n` - const serverEnv = `# Server Environment Configuration -# Generated by init script + return `# Generated by init — do not commit (see .env.example for field docs) -# ==================== Required ==================== +# ==================== Session / at-rest encryption (server only) ==================== +# JWE_SECRET: login session cookies +# ENCRYPTION_KEY: MCP connector secrets in DB (openssl rand -hex 32) JWE_SECRET=${jweSecret} ENCRYPTION_KEY=${encryptionKey} -# ==================== Server Configuration ==================== +# ==================== Server ==================== -PORT=3001 -NODE_ENV=development -DATABASE_PATH=.data/app.db +PORT=${port} +NODE_ENV=${nodeEnv} +DATABASE_PATH=${getPreserved('DATABASE_PATH', '.data/app.db')} -# ==================== Database Provider ==================== +# ==================== Database ==================== DB_PROVIDER=${getPreserved('DB_PROVIDER', 'cloudbase')} DB_COLLECTION_PREFIX=${getPreserved('DB_COLLECTION_PREFIX', 'vibe_agent_')} -# ==================== Rate Limiting ==================== +# ==================== Rate limiting ==================== -MAX_MESSAGES_PER_DAY=${get('MAX_MESSAGES_PER_DAY', '50')} MAX_SANDBOX_DURATION=${get('MAX_SANDBOX_DURATION', '300')} -# ==================== Auth ==================== +# ==================== Auth (runtime reads /api/auth/auth-config; kept for parity) ==================== NEXT_PUBLIC_AUTH_PROVIDERS=${get('NEXT_PUBLIC_AUTH_PROVIDERS', 'local')} -# GitHub login approach: 'direct' (self-managed OAuth) or 'cloudbase' (CloudBase identity source) -AUTH_GITHUB_MODE=${get('AUTH_GITHUB_MODE', 'direct')} - -# ==================== CloudBase ==================== +AUTH_GITHUB_MODE=${getPreserved('AUTH_GITHUB_MODE', 'direct')} +${askUserBlock} +# ==================== CloudBase (platform / provision) ==================== TCB_ENV_ID=${get('TCB_ENV_ID')} TCB_REGION=${get('TCB_REGION', 'ap-shanghai')} @@ -985,47 +869,97 @@ TCB_SECRET_KEY=${get('TCB_SECRET_KEY')} TCB_TOKEN=${get('TCB_TOKEN')} TCB_PROVISION_MODE=${get('TCB_PROVISION_MODE', 'shared')} -# ==================== CodeBuddy Auth ==================== -# 认证方式: API Key(优先)或 OAuth(企业旗舰版) -# 设置 CODEBUDDY_API_KEY 后将跳过 OAuth 认证 +# ==================== CodeBuddy ==================== +# API Key 优先;OAuth 仅企业旗舰版 ${codebuddyConfig.authMode === 'apikey' - ? `CODEBUDDY_API_KEY=${codebuddyConfig.apiKey}` - : `# CODEBUDDY_API_KEY=` - }${codebuddyConfig.internetEnv - ? `\nCODEBUDDY_INTERNET_ENVIRONMENT=${codebuddyConfig.internetEnv}` - : `\n# CODEBUDDY_INTERNET_ENVIRONMENT=internal # 国内版填 internal, iOA 填 ioa` - } + ? `CODEBUDDY_API_KEY=${codebuddyConfig.apiKey}` + : `# CODEBUDDY_API_KEY=` + }${codebuddyConfig.internetEnv + ? `\nCODEBUDDY_INTERNET_ENVIRONMENT=${codebuddyConfig.internetEnv}` + : `\n# CODEBUDDY_INTERNET_ENVIRONMENT=internal` + } ${codebuddyConfig.authMode === 'oauth' - ? `\n# --- OAuth 配置(当前已配置 API Key,OAuth 不生效)---\nCODEBUDDY_CLIENT_ID=${codebuddyConfig.clientId}\nCODEBUDDY_CLIENT_SECRET=${codebuddyConfig.clientSecret}\nCODEBUDDY_OAUTH_ENDPOINT=${codebuddyConfig.oauthEndpoint}` - : `\n# --- OAuth 配置(企业旗舰版,API Key 优先时此项不生效)---\n# CODEBUDDY_CLIENT_ID=\n# CODEBUDDY_CLIENT_SECRET=\n# CODEBUDDY_OAUTH_ENDPOINT=https://copilot.tencent.com/oauth2/token` - } + ? `\nCODEBUDDY_CLIENT_ID=${codebuddyConfig.clientId}\nCODEBUDDY_CLIENT_SECRET=${codebuddyConfig.clientSecret}\nCODEBUDDY_OAUTH_ENDPOINT=${codebuddyConfig.oauthEndpoint}` + : `\n# CODEBUDDY_CLIENT_ID=\n# CODEBUDDY_CLIENT_SECRET=\n# CODEBUDDY_OAUTH_ENDPOINT=https://copilot.tencent.com/oauth2/token` + } GIT_ARCHIVE_REPO=${getPreserved('GIT_ARCHIVE_REPO')} GIT_ARCHIVE_USER=${getPreserved('GIT_ARCHIVE_USER')} GIT_ARCHIVE_TOKEN=${getPreserved('GIT_ARCHIVE_TOKEN')} -# ==================== Stateful Sandbox (AGS + 沙箱业务镜像) ==================== +# ==================== Stateful sandbox ==================== +# TCB_API_KEY: 控制台 → 沙箱 API Key;gateway 由 TCB_ENV_ID 推导 -# TCB_API_KEY: CloudBase console API Key (gateway data plane) -TCB_API_KEY=${get('TCB_API_KEY')} -ENABLE_AUTH_MODE=${get('ENABLE_AUTH_MODE', 'false')} -# TCB_ACCESS_TOKEN= # sit_* when ENABLE_AUTH_MODE=true -STATEFUL_SANDBOX_IMAGE=${get('STATEFUL_SANDBOX_IMAGE', get('TCR_IMAGE'))} -WORKSPACE_ISOLATION=${get('WORKSPACE_ISOLATION', get('SANDBOX_INSTANCE_MODE', 'shared'))} -SANDBOX_TTL_SECONDS=${get('SANDBOX_TTL_SECONDS', '1800')} +TCB_API_KEY=${getPreserved('TCB_API_KEY', get('TCB_API_KEY'))} +${getPreserved('ENABLE_AUTH_MODE') === 'true' + ? `ENABLE_AUTH_MODE=true\nTCB_ACCESS_TOKEN=${getPreserved('TCB_ACCESS_TOKEN')}` + : '# ENABLE_AUTH_MODE=false\n# TCB_ACCESS_TOKEN='} +${getPreserved('STATEFUL_SANDBOX_IMAGE') ? `STATEFUL_SANDBOX_IMAGE=${getPreserved('STATEFUL_SANDBOX_IMAGE')}` : '# STATEFUL_SANDBOX_IMAGE='} +WORKSPACE_ISOLATION=${get('WORKSPACE_ISOLATION', 'shared')} +SANDBOX_TTL_SECONDS=${getPreserved('SANDBOX_TTL_SECONDS', '1800')} -# ==================== GitHub OAuth (Optional) ==================== +# ==================== Optional ==================== # GITHUB_CLIENT_ID= # GITHUB_CLIENT_SECRET= +# http_proxy= +` +} -# ==================== Proxy (Optional) ==================== +async function setupApplicationEnv() { + const isLocal = envWriteTarget === ENV_LOCAL + const targetLabel = isLocal ? '.env.local' : '.env.cloud' + logSection(`写入 ${targetLabel}`) -# http_proxy= + const existingServerEnv = loadEnvFile(envWriteTarget) + const env = loadEnvFile(envWriteTarget) + + if (existsSync(envWriteTarget)) { + const overwrite = await askYesNo(`${targetLabel} 已存在,是否覆盖?`, false) + if (!overwrite) { + log(`跳过 ${targetLabel} 生成`, 'info') + return true + } + } + + const { get, getPreserved } = createEnvResolvers(existingServerEnv, env) + + if (isLocal) { + const header = `# OpenVibeCoding — local development +# Load: packages/server/package.json → pnpm dev (--env-file=../../.env.local) ` + writeFileSync( + ENV_LOCAL, + header + + buildSharedEnvBody(get, getPreserved, { + port: '3001', + nodeEnv: 'development', + askUserBaseUrl: getPreserved('ASK_USER_BASE_URL', 'http://127.0.0.1:3001'), + }), + ) + log('已写入 .env.local', 'success') + return true + } + + console.log('') + console.log(' ASK_USER_BASE_URL:云托管公网根 URL(如 https://xxx.run.tcloudbase.com)') + const cloudUrl = + (await promptInput(' ASK_USER_BASE_URL(回车使用占位,部署后再改)')) || + getPreserved('ASK_USER_BASE_URL', '') - writeFileSync(serverEnvFile, serverEnv) - log('服务端配置已写入 packages/server/.env', 'success') + const header = `# OpenVibeCoding — CloudRun runtime +# Sync: pnpm deploy:cloud → UpdateCloudRunServer EnvParams (not baked into Docker image) +` + writeFileSync( + ENV_CLOUD, + header + + buildSharedEnvBody(get, getPreserved, { + port: '80', + nodeEnv: 'production', + askUserBaseUrl: cloudUrl || 'https://YOUR-SERVICE.run.tcloudbase.com', + }), + ) + log('已写入 .env.cloud', 'success') return true } @@ -1086,71 +1020,43 @@ async function main() { process.exit(1) } - // Step 3: Setup environment (.env.local) - if (!(await setupEnv())) { - process.exit(1) - } - - // Step 4: Check Docker (required for TCR image push) - if (!checkDocker()) { - process.exit(1) - } + await promptEnvGenerationTarget() - // Step 5: CloudBase configuration (TCB_ENV_ID + token) + // CloudBase configuration (TCB_ENV_ID + token) if (!(await setupCloudbaseConfig())) { process.exit(1) } - // Step 6: CodeBuddy auth configuration - // 必须在 setupServerEnv 之前执行,因为 setupServerEnv 会将 codebuddyConfig 写入 .env + // CodeBuddy before env file — values are written into envWriteTarget await setupCodebuddy() - // Step 7: Setup Server Environment (writes packages/server/.env including CodeBuddy config) - if (!(await setupServerEnv())) { - process.exit(1) - } + await setupStatefulSandbox() - // Step 8: Install dependencies (setup-tcr.mjs needs tencentcloud-sdk-nodejs) - if (!(await installDependencies())) { + if (!(await setupApplicationEnv())) { process.exit(1) } - // Step 9: Setup TCR (requires node_modules) - logSection('TCR 配置') - if (!(await setupTcr())) { + if (!(await installDependencies())) { process.exit(1) } - // Step 9.1: Backfill SANDBOX_IMAGE_URI into server .env - // (TCR step writes TCR_IMAGE to root .env.local AFTER setupServerEnv ran) - const rootEnvAfterTcr = loadEnvFile() - if (rootEnvAfterTcr['TCR_IMAGE']) { - const serverEnvFile = resolve(process.cwd(), 'packages/server/.env') - if (existsSync(serverEnvFile)) { - let content = readFileSync(serverEnvFile, 'utf-8') - if (content.includes('SANDBOX_IMAGE_URI=')) { - content = content.replace(/SANDBOX_IMAGE_URI=.*/, `SANDBOX_IMAGE_URI=${rootEnvAfterTcr['TCR_IMAGE']}`) - } else { - content += `\nSANDBOX_IMAGE_URI=${rootEnvAfterTcr['TCR_IMAGE']}\n` - } - writeFileSync(serverEnvFile, content) - log(`已回写 SANDBOX_IMAGE_URI=${rootEnvAfterTcr['TCR_IMAGE']}`, 'success') - } - } - - // Step 10: Initialize database + // --- TCR(Stateful 默认跳过;维护自建沙箱镜像时可取消注释)--- + // if (!checkDocker()) { + // process.exit(1) + // } + // logSection('TCR 配置') + // if (!(await setupTcr())) { + // process.exit(1) + // } + // const rootEnvAfterTcr = loadEnvFile(ENV_LOCAL) + // if (rootEnvAfterTcr['TCR_IMAGE']) { + // saveEnvVar(ENV_LOCAL, 'STATEFUL_SANDBOX_IMAGE', rootEnvAfterTcr['TCR_IMAGE']) + // log('TCR_IMAGE 已写入 STATEFUL_SANDBOX_IMAGE', 'success') + // } + + // Initialize database logSection('初始化数据库') - const serverEnvPath = resolve(process.cwd(), 'packages/server/.env') - const serverEnvVars = existsSync(serverEnvPath) - ? readFileSync(serverEnvPath, 'utf-8').split('\n').reduce((acc, line) => { - const trimmed = line.trim() - if (trimmed && !trimmed.startsWith('#')) { - const [key, ...rest] = trimmed.split('=') - if (key) acc[key.trim()] = rest.join('=').trim() - } - return acc - }, {}) - : {} + const serverEnvVars = loadEnvFile(envWriteTarget) const dbProvider = serverEnvVars['DB_PROVIDER'] || 'cloudbase' @@ -1188,29 +1094,24 @@ async function main() { console.log(' 2. 该仓库的访问令牌(需读写权限)') console.log('') - const configGitArchive = await askYesNo('是否现在配置 Git 归档?', true) + const gitArchiveDefaultYes = envWriteTarget !== ENV_LOCAL + const configGitArchive = await askYesNo('是否现在配置 Git 归档?', gitArchiveDefaultYes) if (configGitArchive) { const gitRepo = await promptInput(' Git 仓库地址(如 https://cnb.cool/org/repo)') const gitUser = await promptInput(' 用户名') const gitToken = await promptInput(' 访问令牌', true) if (gitRepo && gitToken) { - // 写入 server/.env - const sEnvFile = resolve(process.cwd(), 'packages/server/.env') - if (existsSync(sEnvFile)) { - let content = readFileSync(sEnvFile, 'utf-8') - content = content.replace(/GIT_ARCHIVE_REPO=.*/, `GIT_ARCHIVE_REPO=${gitRepo}`) - content = content.replace(/GIT_ARCHIVE_USER=.*/, `GIT_ARCHIVE_USER=${gitUser || ''}`) - content = content.replace(/GIT_ARCHIVE_TOKEN=.*/, `GIT_ARCHIVE_TOKEN=${gitToken}`) - writeFileSync(sEnvFile, content) - log('Git 归档已配置', 'success') - } + saveTargetEnvVar('GIT_ARCHIVE_REPO', gitRepo) + saveTargetEnvVar('GIT_ARCHIVE_USER', gitUser || '') + saveTargetEnvVar('GIT_ARCHIVE_TOKEN', gitToken) + log('Git 归档已写入当前 env 文件', 'success') } else { log('信息不完整,跳过 Git 归档配置', 'warn') } } else { console.log('') - log('已跳过。沙箱重启后工作区内容将不保留,后续可在 packages/server/.env 中手动配置', 'info') + log('已跳过。沙箱重启后工作区内容将不保留,后续可在 env 文件中手动配置', 'info') console.log('') } @@ -1238,52 +1139,40 @@ async function main() { console.log(`${colors.bright}${colors.green}╚══════════════════════════════════════════════╝${colors.reset}`) console.log('') + const envFileName = envWriteTarget === ENV_CLOUD ? '.env.cloud' : '.env.local' + if (codebuddyConfig.authMode) { console.log(`${colors.green}✓${colors.reset} CodeBuddy 认证已配置(${codebuddyConfig.authMode === 'apikey' ? 'API Key' : 'OAuth'})`) } else { - console.log(`${colors.yellow}!${colors.reset} CodeBuddy 认证未配置,启动前请编辑 ${colors.bright}packages/server/.env${colors.reset}`) + console.log(`${colors.yellow}!${colors.reset} CodeBuddy 认证未配置,请编辑 ${colors.bright}${envFileName}${colors.reset}`) } console.log('') - console.log(`${colors.bright}${colors.yellow}━━━ 启动前请确认 ━━━${colors.reset}`) - console.log('') - console.log(`打开 ${colors.bright}packages/server/.env${colors.reset} 确认以下配置:`) - console.log('') - console.log(` ${colors.bright}CodeBuddy 认证${colors.reset} — API Key 或 OAuth 二选一`) - console.log(` ${colors.dim}CODEBUDDY_API_KEY= # API Key(设置后优先,推荐)${colors.reset}`) - console.log(` ${colors.dim}CODEBUDDY_INTERNET_ENVIRONMENT= # 国内版填 internal, iOA 填 ioa${colors.reset}`) - console.log(` ${colors.dim}CODEBUDDY_CLIENT_ID= # OAuth Client ID(企业旗舰版)${colors.reset}`) - console.log(` ${colors.dim}CODEBUDDY_CLIENT_SECRET= # OAuth Client Secret${colors.reset}`) + console.log(`${colors.bright}${colors.yellow}━━━ 下一步 ━━━${colors.reset}`) console.log('') - console.log(`${colors.cyan}━━━ 开发模式 ━━━${colors.reset}`) + console.log(`本次已生成/更新:${colors.bright}${envFileName}${colors.reset}`) console.log('') - console.log(` ${colors.bright}pnpm dev${colors.reset}`) - console.log('') - console.log(`${colors.dim}同时启动前端(端口 5174)和服务端(端口 3001)${colors.reset}`) - console.log(`${colors.dim}在浏览器中打开 http://localhost:5174${colors.reset}`) - console.log('') - console.log(`${colors.cyan}━━━ 生产模式 ━━━${colors.reset}`) - console.log('') - console.log(` ${colors.bright}pnpm build${colors.reset} ${colors.dim}# 构建前端和服务端${colors.reset}`) - console.log( - ` ${colors.bright}pnpm start${colors.reset} ${colors.dim}# 启动服务端(同时托管静态文件)${colors.reset}`, - ) - console.log('') - console.log(`${colors.dim}服务端运行在端口 3001,提供 API 及静态文件服务${colors.reset}`) - console.log('') - console.log(`${colors.cyan}━━━ 其他命令 ━━━${colors.reset}`) - console.log('') - console.log(`${colors.dim} pnpm dev:web - 仅启动前端${colors.reset}`) - console.log(`${colors.dim} pnpm dev:server - 仅启动服务端${colors.reset}`) - console.log(`${colors.dim} pnpm lint - 运行代码检查${colors.reset}`) - console.log(`${colors.dim} pnpm type-check - 检查 TypeScript 类型${colors.reset}`) + + if (envWriteTarget === ENV_LOCAL) { + console.log(`${colors.cyan}本地开发${colors.reset}`) + console.log(` ${colors.bright}pnpm dev${colors.reset} → http://localhost:5174`) + console.log('') + console.log(`${colors.dim}需要云托管配置时,再运行 ./init.sh 并选择 2) .env.cloud${colors.reset}`) + } else { + console.log(`${colors.cyan}云托管部署${colors.reset}`) + console.log(` 确认 ${colors.bright}.env.cloud${colors.reset} 中 ASK_USER_BASE_URL 等为公网地址`) + console.log(` ${colors.bright}pnpm deploy:cloud${colors.reset}`) + console.log('') + console.log(`${colors.dim}需要本地开发时,再运行 ./init.sh 并选择 1) .env.local${colors.reset}`) + console.log(`${colors.dim}deploy 只读 .env.cloud,与 .env.local 无关${colors.reset}`) + } console.log('') } main().then(() => { - if (_rl) _rl.close() + closeReadline() }).catch((error) => { - if (_rl) _rl.close() + closeReadline() console.error('初始化失败:', error) process.exit(1) }) diff --git a/scripts/lib/env-files.mjs b/scripts/lib/env-files.mjs new file mode 100644 index 0000000..05e9ed5 --- /dev/null +++ b/scripts/lib/env-files.mjs @@ -0,0 +1,66 @@ +/** + * Environment file layout (repo root): + * + * .env.example — committed template / documentation only (no secrets) + * .env.local — generated (init option 1): local dev only + * .env.cloud — generated (init option 2): pnpm deploy:cloud reads + syncs to service + */ + +import { existsSync, readFileSync, writeFileSync } from 'fs' +import { resolve } from 'path' + +const ROOT = process.cwd() + +export const ENV_EXAMPLE = resolve(ROOT, '.env.example') +export const ENV_LOCAL = resolve(ROOT, '.env.local') +export const ENV_CLOUD = resolve(ROOT, '.env.cloud') + +/** Keys only used on the deploy machine / CLI — not pushed to CloudRun */ +export const DEPLOY_ONLY_KEYS = new Set(['TCB_TOKEN']) + +/** + * Parse KEY=VALUE lines (no export prefix). Last duplicate key wins. + * @param {string} [filePath] + */ +export function loadEnvFile(filePath = ENV_LOCAL) { + const env = {} + if (!filePath || !existsSync(filePath)) return env + readFileSync(filePath, 'utf-8').split('\n').forEach((line) => { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) return + const eq = trimmed.indexOf('=') + if (eq <= 0) return + const key = trimmed.slice(0, eq).trim() + const value = trimmed.slice(eq + 1).trim() + if (key) env[key] = value + }) + return env +} + +export function saveEnvVar(filePath, key, value) { + const env = loadEnvFile(filePath) + if (env[key] !== undefined) { + const content = readFileSync(filePath, 'utf-8') + const lines = content.split('\n').map((line) => { + if (line.trim().startsWith(`${key}=`)) return `${key}=${value}` + return line + }) + writeFileSync(filePath, lines.join('\n')) + } else { + const content = existsSync(filePath) ? readFileSync(filePath, 'utf-8') : '' + const newline = Object.keys(env).length > 0 ? '\n' : '' + writeFileSync(filePath, `${content}${newline}${key}=${value}`) + } +} + +/** Runtime vars for CloudRun container (from .env.cloud) */ +export function cloudRuntimeEnvFromFile(filePath = ENV_CLOUD) { + const raw = loadEnvFile(filePath) + const out = {} + for (const [key, value] of Object.entries(raw)) { + if (DEPLOY_ONLY_KEYS.has(key)) continue + if (value === '') continue + out[key] = value + } + return out +} diff --git a/scripts/lib/prompt.mjs b/scripts/lib/prompt.mjs new file mode 100644 index 0000000..f7fedbb --- /dev/null +++ b/scripts/lib/prompt.mjs @@ -0,0 +1,74 @@ +/** + * CLI prompts for init / setup scripts. + * Secret fields use @inquirer/password (masked with *). + */ + +import readline from 'node:readline' +import password from '@inquirer/password' + +let _rl = null + +function drainStdin() { + return new Promise((resolve) => { + if (!process.stdin.readable) return resolve() + process.stdin.resume() + const drain = () => { + while (process.stdin.read() !== null) { + /* discard */ + } + } + drain() + setTimeout(() => { + drain() + process.stdin.pause() + resolve() + }, 10) + }) +} + +async function closeReadlineInterface() { + if (_rl) { + _rl.close() + _rl = null + } + await drainStdin() +} + +/** + * @param {string} prompt + * @param {boolean | { hidden?: boolean, defaultValue?: string }} hiddenOrOptions + */ +export async function promptInput(prompt, hiddenOrOptions = false) { + const opts = + typeof hiddenOrOptions === 'object' + ? hiddenOrOptions + : { hidden: Boolean(hiddenOrOptions), defaultValue: '' } + const { hidden = false, defaultValue = '' } = opts + + if (hidden) { + await closeReadlineInterface() + const value = await password({ + message: prompt, + mask: '*', + }) + return (value ?? '').trim() || defaultValue + } + + await closeReadlineInterface() + _rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + return new Promise((resolve) => { + _rl.question(`${prompt}: `, (answer) => { + _rl.close() + _rl = null + resolve(answer.trim() || defaultValue) + }) + }) +} + +/** Call at script exit if the process may keep running (e.g. uncaught handler). */ +export function closeReadline() { + if (_rl) { + _rl.close() + _rl = null + } +} diff --git a/scripts/opencode-setup.mjs b/scripts/opencode-setup.mjs index 9f52190..d90c5dc 100644 --- a/scripts/opencode-setup.mjs +++ b/scripts/opencode-setup.mjs @@ -8,12 +8,12 @@ * - 引导用户选择要启用的 provider 并输入 API key * - 把 provider 启用声明写入 .opencode/opencode.json * (空对象 `{}` 风格,所有元数据由 opencode 运行时从 catalog 自动获取) - * - 把 API key 写入 packages/server/.env + * - 把 API key 写入根目录 .env.local * * 设计约束: * - 只处理项目级配置;不读写 ~/.local/share/opencode/auth.json * - 不做 catalog 本地缓存;每次重新拉取 - * - 凭证只存 packages/server/.env + * - 凭证只存根目录 .env.local * * 用法: * pnpm opencode:setup @@ -23,7 +23,7 @@ import fs from 'node:fs' import path from 'node:path' import readline from 'node:readline' import { createRequire } from 'node:module' - +import { promptInput } from './lib/prompt.mjs' const require = createRequire(import.meta.url) const CloudBaseManager = require('../packages/server/node_modules/@cloudbase/manager-node') let managerApp = null @@ -35,7 +35,9 @@ const CATALOG_FETCH_TIMEOUT_MS = Number(process.env.MODELS_DEV_FETCH_TIMEOUT_MS) const ROOT = process.cwd() const OPENCODE_JSON = path.join(ROOT, '.opencode', 'opencode.json') -const SERVER_ENV_FILE = path.join(ROOT, 'packages', 'server', '.env') +const ENV_TARGET_FILE = process.env.OVC_ENV_FILE + ? path.resolve(process.env.OVC_ENV_FILE) + : path.join(ROOT, '.env.local') const colors = { reset: '\x1b[0m', @@ -67,60 +69,14 @@ function logSection(title) { let _rl = null -function drainStdin() { - return new Promise((resolve) => { - if (!process.stdin.readable) return resolve() - process.stdin.resume() - const drain = () => { - while (process.stdin.read() !== null) { - /* discard */ - } - } - drain() - setTimeout(() => { - drain() - process.stdin.pause() - resolve() - }, 10) - }) -} - async function prompt(question, { hidden = false, defaultValue = '' } = {}) { if (hidden) { - if (_rl) { - _rl.close() - _rl = null - } - await drainStdin() - process.stdout.write(`${question}: `) - process.stdin.setRawMode(true) - process.stdin.resume() - return new Promise((resolve) => { - let buf = '' - const onData = (chunk) => { - const c = chunk.toString('utf8') - if (c === '\n' || c === '\r' || c === '\u0004') { - process.stdin.setRawMode(false) - process.stdin.pause() - process.stdin.removeListener('data', onData) - process.stdout.write('\n') - resolve(buf || defaultValue) - } else if (c === '\u0003') { - process.exit(130) - } else if (c.charCodeAt(0) === 127) { - buf = buf.slice(0, -1) - } else { - buf += c - } - } - process.stdin.on('data', onData) - }) + return promptInput(question, { hidden: true, defaultValue }) } if (_rl) { _rl.close() _rl = null } - await drainStdin() _rl = readline.createInterface({ input: process.stdin, output: process.stdout }) const hint = defaultValue ? ` ${colors.dim}[${defaultValue}]${colors.reset}` : '' return new Promise((resolve) => { @@ -502,7 +458,7 @@ async function collectApiKeys(selected, envNow) { for (const it of selected) { const existing = envNow[it.envKey] if (existing) { - console.log(` ${colors.green}✓${colors.reset} ${it.envKey} 已在 packages/server/.env 中(跳过)`) + console.log(` ${colors.green}✓${colors.reset} ${it.envKey} 已在 env 中(跳过)`) continue } console.log('') @@ -528,7 +484,7 @@ async function collectApiKeys(selected, envNow) { * 收集"已存在但缺 env 的 provider"的 env 值。 * 与 collectApiKeys 区别:这里是补齐,不需要再写 provider 对象到 opencode.json。 */ -async function collectMissingEnvs(envId, rows) { +async function collectMissingEnvs(envId, rows, envNow) { const updates = {} // 把所有 row 的 missingEnv 去重展开成一个待补齐列表 const todoMap = new Map() // envKey -> Set<providerId> @@ -547,6 +503,9 @@ async function collectMissingEnvs(envId, rows) { console.log(`${colors.dim}回车留空 = 跳过该项${colors.reset}`) for (const [envKey, providers] of todoMap) { + if (envKey === 'CLOUDBASE_API_KEY' && envNow['CLOUDBASE_API_KEY']?.trim()) { + continue + } console.log('') let extraHint = '' if (envKey === 'CLOUDBASE_API_KEY') { @@ -630,50 +589,63 @@ async function describeAIModes(envId, secretId, secretKey) { }) return result?.AIModels || [] } catch (err) { - // Non-fatal: server-side SDK uses admin creds and bypasses rules. console.error( - '[open code setup] Failed to describe ai models', + '[opencode setup] Failed to describe ai models', err instanceof Error ? err.message : err, ) } } async function getCloudBaseModelConfig(envId, secretId, secretKey) { - const modelList = await describeAIModes(envId, secretId, secretKey) - let cloudBaseModels = {} + const modelList = (await describeAIModes(envId, secretId, secretKey)) || [] + const cloudBaseModels = {} for (const it of modelList) { - if (it?.GroupName !== "cloudbase") { - continue; + if (it?.GroupName !== 'cloudbase') { + continue } - for (const model of it?.Models){ - cloudBaseModels[model.Model] ={ - id : model.Model, - name : model.Model, + for (const model of it?.Models) { + cloudBaseModels[model.Model] = { + id: model.Model, + name: model.Model, } } } return { - id: "cloudbase", - env: ["CLOUDBASE_API_KEY"], - npm: "", + id: 'cloudbase', + env: ['CLOUDBASE_API_KEY'], + npm: '', api: `https://${envId}.api.tcloudbasegateway.com/v1/ai/cloudbase`, - name: "cloudbase", - doc:"", - models: cloudBaseModels + name: 'cloudbase', + doc: '', + models: cloudBaseModels, } } // ─── Main ──────────────────────────────────────────────────────────────── async function main() { - logSection('OpenCode Provider 配置') + const envLabel = path.basename(ENV_TARGET_FILE) + logSection('OpenCode 自定义模型(数据源:CloudBase AI+)') + console.log('') + console.log(` ${colors.dim}从当前 TCB 环境读取已开通模型 → opencode.json${colors.reset}`) + console.log(` ${colors.dim}env:${envLabel}${colors.reset}`) + console.log('') - const envNow = parseEnvFile(SERVER_ENV_FILE) + const envNow = parseEnvFile(ENV_TARGET_FILE) const envId = envNow['TCB_ENV_ID'] const secretId = envNow['TCB_SECRET_ID'] const secretKey = envNow['TCB_SECRET_KEY'] + if (!envId || !secretId || !secretKey) { + log(`缺少 CloudBase 凭证,请确保 ${envLabel} 含 TCB_*`, 'err') + process.exit(1) + } + if (!envNow['CLOUDBASE_API_KEY']?.trim()) { + log(`缺少 CLOUDBASE_API_KEY,请先 ./init.sh 完成「CloudBase AI API Key」步骤,或写入 ${envLabel}`, 'err') + process.exit(1) + } + // 1. 拉 catalog // log('拉取 models.dev catalog...', 'step') let catalog = {} @@ -688,12 +660,13 @@ async function main() { // process.exit(1) // } - // 仅添加 cloudbase 模型 log('拉取 cloudbase 模型', 'step') const cloudBaseModelConfig = await getCloudBaseModelConfig(envId, secretId, secretKey) if (Object.keys(cloudBaseModelConfig.models).length === 0) { - logSection(`未配置 cloudbase 模型, 请前往 https://tcb.cloud.tencent.com/dev?envId=${envId}#/ai?tab=text-aiModel 开启模型配置` ) + logSection( + `未配置 cloudbase 模型, 请前往 https://tcb.cloud.tencent.com/dev?envId=${envId}#/ai?tab=text-aiModel 开启模型配置`, + ) process.exit(1) } @@ -716,7 +689,7 @@ async function main() { { defaultValue: 'Y' }, ) if (ans.toLowerCase() !== 'n' && ans.toLowerCase() !== 'no') { - missingEnvUpdates = await collectMissingEnvs(envId,missingRows) + missingEnvUpdates = await collectMissingEnvs(envId, missingRows, envNow) } } @@ -740,8 +713,8 @@ async function main() { // 默认仅支持 cloudbase let selected = [] const byId = new Map(items.map((it) => [it.id, it])) - if (byId.has("cloudbase")) { - selected.push(byId.get("cloudbase")) + if (byId.has('cloudbase')) { + selected.push(byId.get('cloudbase')) } @@ -750,7 +723,7 @@ async function main() { if (selected.length > 0) { log(`新增启用:${selected.map((s) => s.id).join(', ')}`, 'ok') logSection('API Key(新增 provider)') - console.log(`${colors.dim}所有 key 将写入 packages/server/.env(已 gitignore)。回车留空则跳过。${colors.reset}`) + console.log(`${colors.dim}所有 key 将写入 ${path.basename(ENV_TARGET_FILE)}。回车留空则跳过。${colors.reset}`) const newKeyUpdates = await collectApiKeys(selected, { ...envNow, ...missingEnvUpdates }) envUpdates = { ...envUpdates, ...newKeyUpdates } } @@ -794,11 +767,11 @@ async function main() { } if (Object.keys(envUpdates).length > 0) { - const { updated, added } = upsertEnvFile(SERVER_ENV_FILE, envUpdates) + const { updated, added } = upsertEnvFile(ENV_TARGET_FILE, envUpdates) const parts = [] if (added.length > 0) parts.push(`新增 ${added.length} 项`) if (updated.length > 0) parts.push(`更新 ${updated.length} 项`) - log(`已写入 ${path.relative(ROOT, SERVER_ENV_FILE)} (${parts.join(',')})`, 'ok') + log(`已写入 ${path.relative(ROOT, ENV_TARGET_FILE)} (${parts.join(',')})`, 'ok') } else { log(`没有新的 env 变更`, 'info') } diff --git a/scripts/reset-user-cam-secrets.ts b/scripts/reset-user-cam-secrets.ts index 4fd7ab7..06bbbb7 100644 --- a/scripts/reset-user-cam-secrets.ts +++ b/scripts/reset-user-cam-secrets.ts @@ -16,9 +16,9 @@ import { config as loadEnv } from 'dotenv' import path from 'node:path' import { fileURLToPath } from 'node:url' -// 加载 packages/server/.env(DB_PROVIDER / TCB_* 等都在那里) +// Load repo root .env.local (DB_PROVIDER / TCB_*) const __dirname = path.dirname(fileURLToPath(import.meta.url)) -loadEnv({ path: path.resolve(__dirname, '../packages/server/.env') }) +loadEnv({ path: path.resolve(__dirname, '../.env.local') }) import { getDb } from '../packages/server/src/db/index.js' diff --git a/scripts/setup-tcr.mjs b/scripts/setup-tcr.mjs index 3b461a7..a8e8b6f 100644 --- a/scripts/setup-tcr.mjs +++ b/scripts/setup-tcr.mjs @@ -22,7 +22,7 @@ import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'fs' import { resolve } from 'path' import { homedir } from 'os' import crypto from 'crypto' -import readline from 'readline' +import { promptInput } from './lib/prompt.mjs' import { createRequire } from 'module' const require = createRequire(import.meta.url) @@ -34,7 +34,7 @@ const tencentcloud = require('tencentcloud-sdk-nodejs') const TCR_DOMAIN = 'ccr.ccs.tencentyun.com' const ENV_FILE = resolve(process.cwd(), '.env.local') -const SERVER_ENV_FILE = resolve(process.cwd(), 'packages/server/.env') +const ENV_LOCAL_FILE = resolve(process.cwd(), '.env.local') const CLOUDBASE_AUTH_FILE = resolve(homedir(), '.config/.cloudbase/auth.json') const DEFAULT_NAMESPACE_PREFIX = 'cloudbase-vibecoding' // docker.io/yhyanghang/cloudbase-workspace:260515-0120e18d @@ -135,14 +135,14 @@ function loadEnvFile() { return env } -/** Mirror TCR image URI into packages/server/.env for first-time CreateSandboxTool. */ +/** Mirror TCR image URI into .env.local for first-time CreateSandboxTool. */ function syncStatefulSandboxImageToServer(tcrImage) { - if (!existsSync(SERVER_ENV_FILE)) { - log('packages/server/.env not found; skip STATEFUL_SANDBOX_IMAGE sync', 'warn') + if (!existsSync(ENV_LOCAL_FILE)) { + log('.env.local not found; skip STATEFUL_SANDBOX_IMAGE sync', 'warn') return } const key = 'STATEFUL_SANDBOX_IMAGE' - const content = readFileSync(SERVER_ENV_FILE, 'utf-8') + const content = readFileSync(ENV_LOCAL_FILE, 'utf-8') const line = `${key}=${tcrImage}` const lines = content.split('\n') let replaced = false @@ -163,8 +163,8 @@ function syncStatefulSandboxImageToServer(tcrImage) { newLines.push('', line) } } - writeFileSync(SERVER_ENV_FILE, newLines.join('\n')) - log('STATEFUL_SANDBOX_IMAGE synced to packages/server/.env', 'success') + writeFileSync(ENV_LOCAL_FILE, newLines.join('\n')) + log('STATEFUL_SANDBOX_IMAGE synced to .env.local', 'success') } function saveEnvVar(key, value) { @@ -187,52 +187,6 @@ function saveEnvVar(key, value) { } } -/** - * Prompt user for input - */ -function promptInput(prompt, hidden = false) { - return new Promise((resolve) => { - if (hidden) { - // Raw mode: disable echo so password is not shown - process.stdout.write(`${prompt}: `) - process.stdin.setRawMode(true) - process.stdin.resume() - let password = '' - const onData = (char) => { - const c = char.toString('utf8') - switch (c) { - case '\n': - case '\r': - case '\u0004': - process.stdin.setRawMode(false) - process.stdin.pause() - process.stdin.removeListener('data', onData) - process.stdout.write('\n') - resolve(password) - break - case '\u0003': - process.exit() - break - default: - if (c.charCodeAt(0) === 127) { - password = password.slice(0, -1) - } else { - password += c - } - break - } - } - process.stdin.on('data', onData) - } else { - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) - rl.question(`${prompt}: `, (answer) => { - rl.close() - resolve(answer.trim()) - }) - } - }) -} - /** * Ask user yes/no question */ From edd890bf835f3aeccdf586c7eb6ac668a0a62dfd Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Wed, 27 May 2026 19:10:46 +0800 Subject: [PATCH 22/29] fix(web): unblock Docker build and improve cloud ASK_USER_BASE_URL Remove stale layout/AppLayout and a redundant phase check that failed tsc. Let cloud init use an ASK_USER_BASE_URL placeholder and write it back on deploy. Co-authored-by: Cursor <cursoragent@cursor.com> --- .env.example | 2 +- docs/setup.md | 2 +- .../server/scripts/stop-stateful-instances.ts | 3 +- .../src/components/chat/turn-status-lines.tsx | 2 +- .../web/src/components/layout/AppLayout.tsx | 163 ------------------ scripts/deploy.mjs | 22 ++- scripts/init.mjs | 19 +- scripts/lib/env-files.mjs | 19 ++ 8 files changed, 50 insertions(+), 182 deletions(-) delete mode 100644 packages/web/src/components/layout/AppLayout.tsx diff --git a/.env.example b/.env.example index 1fc8405..11a4109 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,7 @@ ENCRYPTION_KEY= # |-------------------|-------------------|-------------------| # | PORT | 3001 | 80 | # | NODE_ENV | development | production | -# | ASK_USER_BASE_URL | http://127.0.0.1:3001 | https://*.run.tcloudbase.com | +# | ASK_USER_BASE_URL | http://127.0.0.1:3001 | 公网根 URL;init 可占位,deploy:cloud 首次成功后写回 | PORT=3001 NODE_ENV=development diff --git a/docs/setup.md b/docs/setup.md index e625f88..95985d7 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -101,7 +101,7 @@ flowchart TD - 每次 `./init.sh` **只生成一个文件**:选 1 → `.env.local`,选 2 → `.env.cloud`。 - 两个都要:先跑一遍选 1,再跑一遍选 2(CloudBase / CodeBuddy 等步骤会再走一遍)。 - **覆盖**:仅针对本次选中的文件询问;选「否」则跳过写入。 -- 选 **2** 时会多问 `ASK_USER_BASE_URL`(云托管公网根 URL)。 +- 选 **2** 时会问 `ASK_USER_BASE_URL`(云托管公网根 URL);首次部署前可回车用占位,`pnpm deploy:cloud` 在能读到默认域名时会写回 `.env.cloud`。 ## 本地开发流程 diff --git a/packages/server/scripts/stop-stateful-instances.ts b/packages/server/scripts/stop-stateful-instances.ts index 5b1e8eb..b17ff17 100644 --- a/packages/server/scripts/stop-stateful-instances.ts +++ b/packages/server/scripts/stop-stateful-instances.ts @@ -32,8 +32,7 @@ async function callAgsManagerApi(action: string, param: Record<string, unknown>) const CloudService = ((managerUtilsModule as any).CloudService || (managerUtilsModule as any).default?.CloudService) as any - const secretId = - process.env.TCB_SECRET_ID || '' + const secretId = process.env.TCB_SECRET_ID || '' const secretKey = process.env.TCB_SECRET_KEY || '' const token = process.env.TCB_TOKEN || process.env.TENCENTCLOUD_SESSIONTOKEN || '' const managerEnvId = process.env.TCB_ENV_ID || '' diff --git a/packages/web/src/components/chat/turn-status-lines.tsx b/packages/web/src/components/chat/turn-status-lines.tsx index a4084fe..38d1db1 100644 --- a/packages/web/src/components/chat/turn-status-lines.tsx +++ b/packages/web/src/components/chat/turn-status-lines.tsx @@ -110,7 +110,7 @@ function AgentActivityLine({ // Sandbox done but model has not started streaming yet. if (sandbox.status === 'success' || sandbox.status === 'failed') { - if (!hasAgentContent && phase !== 'tool_executing') { + if (!hasAgentContent) { const label = sandbox.status === 'failed' ? '等待模型(受限模式)…' : '等待模型响应…' return <StatusLine icon={Sparkles} iconClass="text-primary/70" label={label} spinning muted /> } diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx deleted file mode 100644 index 14a83d4..0000000 --- a/packages/web/src/components/layout/AppLayout.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { Link, useLocation } from 'react-router' -import { useTasks } from '../../hooks/use-tasks' -import { cn } from '../../lib/utils' -import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import { Plus, Menu, AlertCircle, Loader2 } from 'lucide-react' -import { useState } from 'react' - -interface AppLayoutProps { - children: React.ReactNode -} - -export function AppLayout({ children }: AppLayoutProps) { - const { tasks, isLoading } = useTasks() - const location = useLocation() - const [sidebarOpen, setSidebarOpen] = useState(true) - - const getStatusBadge = (status: string) => { - switch (status) { - case 'completed': - return ( - <Badge - variant="secondary" - className="text-[10px] px-1.5 py-0 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" - > - Done - </Badge> - ) - case 'processing': - return ( - <Badge - variant="secondary" - className="text-[10px] px-1.5 py-0 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400" - > - Running - </Badge> - ) - case 'error': - return ( - <Badge variant="destructive" className="text-[10px] px-1.5 py-0"> - Error - </Badge> - ) - case 'stopped': - return ( - <Badge - variant="secondary" - className="text-[10px] px-1.5 py-0 bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" - > - Stopped - </Badge> - ) - default: - return ( - <Badge variant="secondary" className="text-[10px] px-1.5 py-0"> - Pending - </Badge> - ) - } - } - - return ( - <div className="flex h-screen bg-background overflow-hidden"> - {/* Sidebar */} - <div - className={cn( - 'h-full border-r bg-muted flex flex-col shrink-0 transition-all duration-300', - sidebarOpen ? 'w-72' : 'w-0 overflow-hidden', - )} - > - <div className="px-3 pt-5 pb-4"> - <div className="flex items-center justify-between mb-3"> - <span className="text-xs font-medium tracking-wide text-foreground px-2 py-1">Tasks</span> - <div className="flex items-center gap-1"> - <Link to="/"> - <Button variant="ghost" size="sm" className="h-8 w-8 p-0" title="New Task"> - <Plus className="h-4 w-4" /> - </Button> - </Link> - </div> - </div> - </div> - - <div className="flex-1 overflow-y-auto px-3 pb-3 space-y-1"> - {isLoading ? ( - <Card> - <CardContent className="p-3 flex items-center justify-center gap-2 text-xs text-muted-foreground"> - <Loader2 className="h-3 w-3 animate-spin" /> - Loading tasks... - </CardContent> - </Card> - ) : tasks.length === 0 ? ( - <Card> - <CardContent className="p-3 text-center text-xs text-muted-foreground"> - No tasks yet. Create your first task! - </CardContent> - </Card> - ) : ( - tasks.map((task) => { - const isActive = location.pathname === `/tasks/${task.id}` - const displayText = task.title || task.prompt - const truncatedText = displayText.slice(0, 50) + (displayText.length > 50 ? '...' : '') - - return ( - <Link - key={task.id} - to={`/tasks/${task.id}`} - className={cn('block rounded-lg', isActive && 'ring-1 ring-primary/50 ring-offset-0')} - > - <Card - className={cn( - 'cursor-pointer transition-colors hover:bg-accent p-0 rounded-lg', - isActive && 'bg-accent', - )} - > - <CardContent className="px-3 py-2"> - <div className="flex gap-2"> - <div className="flex-1 min-w-0"> - <div className="flex items-center justify-between gap-1"> - <h3 - className={cn( - 'text-xs font-medium truncate mb-0.5', - task.status === 'processing' && - 'bg-gradient-to-r from-muted-foreground from-20% via-white via-50% to-muted-foreground to-80% bg-clip-text text-transparent bg-[length:300%_100%] animate-[shimmer_1.5s_linear_infinite]', - )} - > - {truncatedText} - </h3> - {task.status === 'error' && <AlertCircle className="h-3 w-3 text-red-500 flex-shrink-0" />} - {task.status === 'stopped' && ( - <AlertCircle className="h-3 w-3 text-orange-500 flex-shrink-0" /> - )} - </div> - <div className="flex items-center gap-2 text-xs text-muted-foreground"> - {getStatusBadge(task.status)} - {task.selectedAgent && <span className="truncate capitalize">{task.selectedAgent}</span>} - </div> - </div> - </div> - </CardContent> - </Card> - </Link> - ) - }) - )} - </div> - </div> - - {/* Main content area */} - <div className="flex-1 flex flex-col overflow-hidden"> - {/* Header with menu toggle */} - <div className="px-3 pt-3 pb-1.5 flex items-center"> - <Button onClick={() => setSidebarOpen(!sidebarOpen)} variant="ghost" size="sm" className="h-8 w-8 p-0"> - <Menu className="h-4 w-4" /> - </Button> - </div> - {/* Page content */} - <div className="flex-1 overflow-hidden">{children}</div> - </div> - </div> - ) -} diff --git a/scripts/deploy.mjs b/scripts/deploy.mjs index 32626a1..a62038d 100755 --- a/scripts/deploy.mjs +++ b/scripts/deploy.mjs @@ -17,7 +17,14 @@ import { execSync } from 'child_process' import { createRequire } from 'module' import { existsSync, readFileSync, writeFileSync } from 'fs' import { resolve } from 'path' -import { ENV_CLOUD, loadEnvFile, cloudRuntimeEnvFromFile } from './lib/env-files.mjs' +import { + ENV_CLOUD, + loadEnvFile, + cloudRuntimeEnvFromFile, + isAskUserBaseUrlUnset, + normalizeAskUserBaseUrl, + saveEnvVar, +} from './lib/env-files.mjs' const require = createRequire(import.meta.url) const CloudBase = require('@cloudbase/manager-node') @@ -147,10 +154,11 @@ async function deployCloudRun(deployEnv, options) { log('未找到 .env.cloud,请运行 ./init.sh 生成或手动创建', 'warn') } else { const runtimeEnv = cloudRuntimeEnvFromFile(ENV_CLOUD) - if (accessUrl && !runtimeEnv.ASK_USER_BASE_URL) { - runtimeEnv.ASK_USER_BASE_URL = accessUrl.startsWith('http') - ? accessUrl - : `https://${accessUrl}` + if (accessUrl && isAskUserBaseUrlUnset(runtimeEnv.ASK_USER_BASE_URL)) { + const normalized = normalizeAskUserBaseUrl(accessUrl) + runtimeEnv.ASK_USER_BASE_URL = normalized + saveEnvVar(ENV_CLOUD, 'ASK_USER_BASE_URL', normalized) + log('已从云托管默认域名写回 .env.cloud 的 ASK_USER_BASE_URL', 'success') } try { await syncCloudRunEnv(app, envId, DEFAULT_SERVICE_NAME, runtimeEnv) @@ -167,6 +175,10 @@ async function deployCloudRun(deployEnv, options) { console.log(` ${colors.bright}服务:${colors.reset}${DEFAULT_SERVICE_NAME}`) if (accessUrl) { console.log(` ${colors.bright}访问地址:${colors.reset}${accessUrl}`) + } else if (existsSync(ENV_CLOUD) && isAskUserBaseUrlUnset(loadEnvFile(ENV_CLOUD).ASK_USER_BASE_URL)) { + console.log( + ` ${colors.yellow}ASK_USER_BASE_URL 仍为占位:部署完成后到控制台复制默认域名写入 .env.cloud 再执行 deploy:cloud${colors.reset}`, + ) } console.log(` ${colors.bright}构建进度:${colors.reset}`) console.log( diff --git a/scripts/init.mjs b/scripts/init.mjs index e92c264..78d8784 100644 --- a/scripts/init.mjs +++ b/scripts/init.mjs @@ -19,6 +19,7 @@ import crypto from 'crypto' import { ENV_LOCAL, ENV_CLOUD, + ASK_USER_BASE_URL_PLACEHOLDER, loadEnvFile, saveEnvVar, } from './lib/env-files.mjs' @@ -735,10 +736,7 @@ async function setupCustomModel() { console.log('') const setupCodeBuddyModel = await askYesNo('是否配置 CodeBuddy 自定义模型(models.json)', false) - const setupOpenCodeModel = await askYesNo( - '是否配置 OpenCode 自定义模型(opencode.json)', - envWriteTarget !== ENV_LOCAL, - ) + const setupOpenCodeModel = await askYesNo('是否配置 OpenCode 自定义模型(opencode.json)', false) if (setupCodeBuddyModel || setupOpenCodeModel) { if (!(await ensureCloudbaseApiKey())) { @@ -942,9 +940,12 @@ async function setupApplicationEnv() { } console.log('') - console.log(' ASK_USER_BASE_URL:云托管公网根 URL(如 https://xxx.run.tcloudbase.com)') + console.log(' ASK_USER_BASE_URL:Agent 向用户提问时拼接链接用的云托管公网根 URL') + console.log(' 首次部署前控制台往往还没有默认域名,可直接回车用占位。') + console.log(` 占位:${ASK_USER_BASE_URL_PLACEHOLDER}`) + console.log(' 首次 pnpm deploy:cloud 成功后会尝试从云托管读取域名并写回 .env.cloud') const cloudUrl = - (await promptInput(' ASK_USER_BASE_URL(回车使用占位,部署后再改)')) || + (await promptInput(' ASK_USER_BASE_URL(回车=占位,部署后自动/手动改)')) || getPreserved('ASK_USER_BASE_URL', '') const header = `# OpenVibeCoding — CloudRun runtime @@ -956,7 +957,7 @@ async function setupApplicationEnv() { buildSharedEnvBody(get, getPreserved, { port: '80', nodeEnv: 'production', - askUserBaseUrl: cloudUrl || 'https://YOUR-SERVICE.run.tcloudbase.com', + askUserBaseUrl: cloudUrl || ASK_USER_BASE_URL_PLACEHOLDER, }), ) log('已写入 .env.cloud', 'success') @@ -1160,8 +1161,8 @@ async function main() { console.log(`${colors.dim}需要云托管配置时,再运行 ./init.sh 并选择 2) .env.cloud${colors.reset}`) } else { console.log(`${colors.cyan}云托管部署${colors.reset}`) - console.log(` 确认 ${colors.bright}.env.cloud${colors.reset} 中 ASK_USER_BASE_URL 等为公网地址`) - console.log(` ${colors.bright}pnpm deploy:cloud${colors.reset}`) + console.log(` ${colors.bright}pnpm deploy:cloud${colors.reset} (ASK_USER_BASE_URL 占位可在首次部署后写回)`) + console.log(` 若仍为占位,到控制台复制默认域名后改 ${colors.bright}.env.cloud${colors.reset} 再部署一次`) console.log('') console.log(`${colors.dim}需要本地开发时,再运行 ./init.sh 并选择 1) .env.local${colors.reset}`) console.log(`${colors.dim}deploy 只读 .env.cloud,与 .env.local 无关${colors.reset}`) diff --git a/scripts/lib/env-files.mjs b/scripts/lib/env-files.mjs index 05e9ed5..69228ef 100644 --- a/scripts/lib/env-files.mjs +++ b/scripts/lib/env-files.mjs @@ -18,6 +18,25 @@ export const ENV_CLOUD = resolve(ROOT, '.env.cloud') /** Keys only used on the deploy machine / CLI — not pushed to CloudRun */ export const DEPLOY_ONLY_KEYS = new Set(['TCB_TOKEN']) +/** Written by init when user skips ASK_USER_BASE_URL before first deploy */ +export const ASK_USER_BASE_URL_PLACEHOLDER = 'https://YOUR-SERVICE.run.tcloudbase.com' + +/** True when URL is empty, init placeholder, or obviously local-only */ +export function isAskUserBaseUrlUnset(url) { + const v = (url || '').trim() + if (!v) return true + if (v === ASK_USER_BASE_URL_PLACEHOLDER) return true + if (/YOUR-SERVICE/i.test(v)) return true + if (/^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?\/?$/i.test(v)) return true + return false +} + +export function normalizeAskUserBaseUrl(url) { + const v = (url || '').trim() + if (!v) return '' + return /^https?:\/\//i.test(v) ? v : `https://${v}` +} + /** * Parse KEY=VALUE lines (no export prefix). Last duplicate key wins. * @param {string} [filePath] From c5cf2d4b8d38b1434b26471cdd83b16b49908b28 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Wed, 27 May 2026 19:36:42 +0800 Subject: [PATCH 23/29] docs(deploy): clarify local vs cloud paths and improve deploy UX Document split env workflow in README; deploy script prints console link early, streams CLI with heartbeats, polls CloudRun status, and writes back sh.run URLs. Co-authored-by: Cursor <cursoragent@cursor.com> --- README-zh.md | 92 +++++++---- README.md | 62 ++++---- docs/cloudrun-deploy.md | 52 +++++-- docs/setup.md | 9 +- scripts/deploy.mjs | 331 +++++++++++++++++++++++++++++++++++----- 5 files changed, 422 insertions(+), 124 deletions(-) diff --git a/README-zh.md b/README-zh.md index 8a24353..10c429e 100644 --- a/README-zh.md +++ b/README-zh.md @@ -113,73 +113,102 @@ --- -## 初始化 +## 快速开始 + +环境文件 **刻意分开**,避免把本地密钥打进云镜像、或把云配置误用于 `pnpm dev`: + +| | 本地开发 | 部署到云托管 | +| --- | --- | --- | +| **初始化** | `./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 +./init.sh # 按上表选 1 或 2 +pnpm dev # 本地 +# 或 +pnpm deploy:cloud # 云托管(需 cloudbase CLI) ``` -`init.sh` 开头会让你 **二选一**(没有「两个都生成」): +--- + +## 初始化 + +`./init.sh` 检查 Node / pnpm 后执行 `scripts/init.mjs`。每次 **二选一**(不会一次生成两份 env): -| 选项 | 生成 | 用途 | +| 选项 | 生成文件 | 用途 | | --- | --- | --- | -| **1** | `.env.local` | 本地 `pnpm dev` | -| **2** | `.env.cloud` | `pnpm deploy:cloud`(CLI 凭证 + 同步到云托管运行时) | +| **1** | `.env.local` | `pnpm dev` / `pnpm dev:server`(`--env-file=../../.env.local`) | +| **2** | `.env.cloud` | `pnpm deploy:cloud`(CLI 凭证 + 尝试同步到云托管运行时) | -本地 + 云端都要用时:**跑两次** `./init.sh`,各选一次。字段说明见 [.env.example](.env.example);流程见 [docs/setup.md](docs/setup.md#配置文件职责)。 +完整流程与排障:[docs/setup.md](docs/setup.md#配置文件职责)。 --- ## 本地开发 -**前置**:Node **22.x**(`.nvmrc`)、pnpm 10+、CloudBase 支撑环境 + API 密钥、CodeBuddy 或 OpenCode API Key(`npm i -g opencode-ai`)。本机只跑 OpenVibeCoding 前后端;编码沙箱在云端 Stateful + 沙箱业务镜像。 +**前置**:Node **22.x**(`.nvmrc`)、pnpm 10+、CloudBase 支撑环境 + API 密钥、CodeBuddy 或 OpenCode(`npm i -g opencode-ai`)。本机只跑 OpenVibeCoding 前后端;**编码沙箱在云端**(Stateful + 沙箱业务镜像),不在本机 Docker 里跑任务。 -**环境**:只读根目录 **`.env.local`**(`pnpm dev:server` → `--env-file=../../.env.local`)。Vite 把 `/api` 代理到 `:3001`,前端不需要单独 env 文件。 +**环境**:只读根目录 **`.env.local`**。Vite 将 `/api` 代理到 `:3001`,前端无需单独 env。 -**Agent**:任务可选 **CodeBuddy** 或 **OpenCode**(`opencode-acp`),共用同一套沙箱。 +**Agent**:任务可选 **CodeBuddy** 或 **OpenCode**(`opencode-acp`),共用同一套云沙箱。 ```bash -pnpm dev # Web http://localhost:5174 API http://localhost:3001/api +pnpm dev # Web :5174 + API :3001 +pnpm dev:web # 仅前端 +pnpm dev:server # 仅后端 +pnpm build && pnpm start # 本机一体启动(仍读本机环境,≠ 云托管) ``` -| 变量(在 `.env.local`) | 作用 | +| 变量(`.env.local`) | 作用 | | --- | --- | -| `JWE_SECRET` / `ENCRYPTION_KEY` | 会话与 MCP 密文加密 | +| `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)。 - -```bash -pnpm dev:web # 仅前端 -pnpm dev:server # 仅后端 -pnpm build && pnpm start # 本机生产形态(仍读进程环境,非云托管) -``` +Tool 名 `openvibecoding-{TCB_ENV_ID}` 自动创建/复用,**勿**配置 `STATEFUL_TOOL_ID`。排障见 [docs/setup.md](docs/setup.md)。 --- ## 部署到云托管 -与本地开发 **分开**:不跑 `pnpm dev`,用 CloudBase CLI 提交镜像,运行时 env 来自 **`.env.cloud`**(`pnpm deploy:cloud` 在部署后 API 同步,不必手抄控制台)。 +与本地 **完全分开**:不要在本机 `pnpm dev` 的同时指望 `.env.cloud` 生效;部署读 **`.env.cloud`**,用 CloudBase CLI 上传源码,在云端按 `Dockerfile` 构建镜像。 **前置** -- 已 `./init.sh` 并选择 **2** 生成 `.env.cloud`(含 `TCB_SECRET_*`、`TCB_ENV_ID`) -- `npm i -g @cloudbase/cli` 且已 `cloudbase login` -- 核对 `.env.cloud` 中 `ASK_USER_BASE_URL` 为公网根 URL(可先占位,首次部署后按控制台域名改再部署) +- `./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. 用 `.env.cloud` 的凭证提交源码 + `Dockerfile` 云端构建 -2. 部署服务 `vibecoding-platform`(端口 80) -3. 将 `.env.cloud` 同步到云托管 EnvParams(`--skip-env-sync` 可跳过) +脚本会: + +1. **立即**打印云托管控制台链接(部署记录 / 构建日志) +2. 提交源码(上传阶段无百分比,约每 15s 一行心跳提示) +3. **轮询**服务/部署记录状态,直到本次发布生效或失败 +4. 若 `ASK_USER_BASE_URL` 仍为占位,用 `*.sh.run.tcloudbase.com` 写回 `.env.cloud` +5. 尝试把 `.env.cloud` 同步到云托管环境变量(失败则提示控制台手贴) + +| 标志 | 含义 | +| --- | --- | +| `--no-wait` | 只提交构建,不轮询(CI / 自行看控制台) | +| `--skip-env-sync` | 不同步环境变量到云托管 | + +服务名 **`vibecoding-platform`**,对外端口 **80**。沙箱 gateway 的 **9000** 与云托管监听端口无关。 -详情:[docs/cloudrun-deploy.md](docs/cloudrun-deploy.md)。构建进度:控制台 → 云托管 → 部署记录。 +详情:[docs/cloudrun-deploy.md](docs/cloudrun-deploy.md)。 ## 常用命令 @@ -420,9 +449,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 4bbf4ff..e9f9a28 100644 --- a/README.md +++ b/README.md @@ -115,72 +115,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 diff --git a/docs/cloudrun-deploy.md b/docs/cloudrun-deploy.md index 6911b63..b669851 100644 --- a/docs/cloudrun-deploy.md +++ b/docs/cloudrun-deploy.md @@ -1,6 +1,17 @@ # CloudRun 部署(云托管) -一体容器(`Dockerfile`)监听 **80**。密钥**不打进镜像**(`.dockerignore` 排除 `.env*`)。 +一体容器(`Dockerfile`)在进程内监听 **`PORT`(云端为 80)**。密钥**不打进镜像**(`.dockerignore` 排除 `.env*`)。 + +## 和本地开发的关系 + +| | 本地 | 云托管 | +| --- | --- | --- | +| 环境文件 | `.env.local` | `.env.cloud` | +| 启动 | `pnpm dev` | `pnpm deploy:cloud` | +| 对外端口 | 3001 + Vite 5174 | **80** | +| 沙箱 TRW | 经 gateway,容器内 **9000** | 同上(与云托管 80 **无关**) | + +不要在云托管控制台把服务端口改成 9000。 ## 环境文件 @@ -8,9 +19,9 @@ | --- | --- | | `.env.example` | 文档模板(可提交) | | `.env.local` | 仅本地 `pnpm dev` | -| `.env.cloud` | `pnpm deploy:cloud` 读此文件(CLI 凭证 + 部署后同步到服务的运行时变量) | +| `.env.cloud` | `pnpm deploy:cloud`:CLI 凭证 + 运行时变量(尝试 API 同步) | -`./init.sh` 每次只生成其一:选 1 → `.env.local`,选 2 → `.env.cloud`;两份都要则跑两次 init。云端差异主要是 `PORT`、`NODE_ENV`、`ASK_USER_BASE_URL`。 +`./init.sh` 每次只生成其一;两份都要则 init 跑两次。云端常见差异:`PORT=80`、`NODE_ENV=production`、`ASK_USER_BASE_URL` 为公网根 URL。 ## 一键部署 @@ -18,24 +29,33 @@ pnpm deploy:cloud ``` -1. 使用 `.env.cloud` 中的 `TCB_*` 调用 CloudBase CLI 上传源码并云端构建 -2. 部署完成后将 `.env.cloud` 同步到服务 `EnvParams`(无需手抄控制台) -3. 若 API 同步失败,脚本会提示到控制台粘贴 `.env.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 --skip-env-sync +pnpm deploy:cloud --no-wait # 只提交,不轮询(CI / 自己盯控制台) +pnpm deploy:cloud --skip-env-sync # 不同步环境变量 ``` -## 部署后 +### 建议 + +- 在**本机终端**跑完整部署,避免 IDE 内置终端 2–3 分钟超时打断上传 +- 构建失败:控制台 → 部署记录 → 查看 Docker 构建日志(与本地 `pnpm build` 同源) -- 构建进度:控制台 → 云托管 → 服务 `vibecoding-platform` → 部署记录 -- 确认 `ASK_USER_BASE_URL` 为公网根 URL(勿用 `127.0.0.1`) -- 勿配置 `STATEFUL_TOOL_ID`(多副本用 DB + `openvibecoding-{TCB_ENV_ID}`) +## 部署后检查 -## 验证 +- 默认域名可访问:`GET /health` +- `.env.cloud` 中 `ASK_USER_BASE_URL` 与浏览器打开的公网根 URL 一致(勿为 `127.0.0.1`) +- 登录、创建任务,确认沙箱与预览 +- **勿**配置 `STATEFUL_TOOL_ID`(多副本用 DB + `openvibecoding-{TCB_ENV_ID}`) -1. `GET /health` -2. 登录并创建任务,检查沙箱与预览 -3. 沙箱失败见 [setup.md](./setup.md) 排障 +沙箱问题见 [setup.md](./setup.md) 排障章节。 diff --git a/docs/setup.md b/docs/setup.md index 95985d7..f7f376e 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -113,11 +113,12 @@ flowchart TD ## 部署到云托管 -与本地开发分开,见 [cloudrun-deploy.md](./cloudrun-deploy.md): +与本地开发 **完全分开**(不共用 `.env.local`)。详见 [cloudrun-deploy.md](./cloudrun-deploy.md)。 -1. 确认 `.env.cloud` 含 `TCB_SECRET_*`、`TCB_ENV_ID`(init 选 2 生成) -2. 编辑 `.env.cloud`(尤其 `ASK_USER_BASE_URL`) -3. `pnpm deploy:cloud` → 构建 + 同步 `.env.cloud` 到服务 +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` 中的运行时变量 ## 关键环境变量 diff --git a/scripts/deploy.mjs b/scripts/deploy.mjs index a62038d..22086ed 100755 --- a/scripts/deploy.mjs +++ b/scripts/deploy.mjs @@ -11,9 +11,10 @@ * Usage: * pnpm deploy:cloud * pnpm deploy:cloud --skip-env-sync # deploy only, do not call UpdateCloudRunServer + * pnpm deploy:cloud --no-wait # submit source only, do not poll until service is ready */ -import { execSync } from 'child_process' +import { execSync, spawn } from 'child_process' import { createRequire } from 'module' import { existsSync, readFileSync, writeFileSync } from 'fs' import { resolve } from 'path' @@ -32,6 +33,11 @@ const CloudBase = require('@cloudbase/manager-node') const ROOT = process.cwd() const CLOUDBASERC = resolve(ROOT, 'cloudbaserc.json') const DEFAULT_SERVICE_NAME = 'vibecoding-platform' +const TCBR_API_VERSION = '2022-02-17' + +const POLL_INTERVAL_MS = 10_000 +const POLL_TIMEOUT_MS = 45 * 60 * 1000 +const UPLOAD_HEARTBEAT_MS = 15_000 const colors = { reset: '\x1b[0m', @@ -58,6 +64,10 @@ function logSection(title) { console.log(`${colors.bright}${colors.cyan}━━━ ${title} ━━━${colors.reset}`) } +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + function commandExists(name) { try { execSync(`which ${name}`, { stdio: 'pipe' }) @@ -67,9 +77,16 @@ function commandExists(name) { } } -function run(cmd, options = {}) { - console.log(` ${colors.dim}$ ${cmd}${colors.reset}`) - execSync(cmd, { stdio: 'inherit', cwd: ROOT, ...options }) +function deployConsoleUrl(envId, serverName = DEFAULT_SERVICE_NAME) { + return `https://tcb.cloud.tencent.com/dev?envId=${envId}#/platform-run/service/detail?serverName=${serverName}&tabId=deploy&envId=${envId}` +} + +function printConsoleLink(envId, serverName = DEFAULT_SERVICE_NAME) { + const url = deployConsoleUrl(envId, serverName) + console.log('') + log('云托管控制台(构建/部署记录)', 'info') + console.log(` ${colors.bright}${url}${colors.reset}`) + console.log('') } function createCloudBaseApp(env) { @@ -80,6 +97,207 @@ function createCloudBaseApp(env) { }) } +function pickAccessUrl(detail) { + const base = detail?.BaseInfo + if (!base) return '' + return ( + base.DefaultDomainName || + base.CustomDomainName || + (Array.isArray(base.CustomDomainNames) ? base.CustomDomainNames[0] : '') || + '' + ) +} + +/** @returns {string} normalized URL if written */ +function maybeWritebackAskUserBaseUrl(accessUrl) { + if (!accessUrl || !existsSync(ENV_CLOUD)) return '' + const current = loadEnvFile(ENV_CLOUD).ASK_USER_BASE_URL + if (!isAskUserBaseUrlUnset(current)) return '' + const normalized = normalizeAskUserBaseUrl(accessUrl) + saveEnvVar(ENV_CLOUD, 'ASK_USER_BASE_URL', normalized) + log(`已写回 .env.cloud 的 ASK_USER_BASE_URL`, 'success') + console.log(` ${colors.dim}${normalized}${colors.reset}`) + return normalized +} + +function isDeploySettledStatus(status) { + const s = String(status || '').toLowerCase() + return s === 'running' || s === 'normal' || s === 'active' +} + +function isDeployFailedStatus(status) { + const s = String(status || '').toLowerCase() + return s.includes('fail') || s === 'error' || s === 'abnormal' +} + +function isDeployInProgressStatus(status) { + const s = String(status || '').toLowerCase() + if (!s) return true + if (isDeploySettledStatus(s) || isDeployFailedStatus(s)) return false + return true +} + +/** + * Submit source via cloudbase CLI (streams output; auto-answers known prompts). + */ +function runCloudRunDeploy(serviceName) { + return new Promise((resolve, reject) => { + const args = ['cloudrun', 'deploy', '-s', serviceName, '--port', '80', '--force', '--source', '.'] + console.log(` ${colors.dim}$ cloudbase ${args.join(' ')}${colors.reset}`) + + const child = spawn('cloudbase', args, { cwd: ROOT, stdio: ['pipe', 'pipe', 'pipe'] }) + let answeredGray = false + let answeredConcurrent = false + + const onChunk = (chunk) => { + const text = chunk.toString() + process.stdout.write(text) + if (text.includes('Enable gray deployment') && !answeredGray) { + answeredGray = true + child.stdin.write('\n') + } + if (text.includes('deployment tasks running') && !answeredConcurrent) { + answeredConcurrent = true + child.stdin.write('Y\n') + } + } + + child.stdout.on('data', onChunk) + child.stderr.on('data', onChunk) + + const heartbeat = setInterval(() => { + log('仍在上传/提交源码(CLI 无进度条,属正常)…', 'info') + }, UPLOAD_HEARTBEAT_MS) + + const grayFallback = setTimeout(() => { + if (!answeredGray) { + answeredGray = true + child.stdin.write('\n') + } + }, 4000) + + child.on('error', (err) => { + clearInterval(heartbeat) + clearTimeout(grayFallback) + reject(err) + }) + + child.on('close', (code) => { + clearInterval(heartbeat) + clearTimeout(grayFallback) + if (code === 0) resolve() + else reject(new Error(`cloudbase deploy exited with code ${code}`)) + }) + }) +} + +async function fetchServerDetail(app, serverName) { + return app.cloudrun.detail({ serverName }) +} + +function isDeployRecordFailed(status) { + const s = String(status || '').toLowerCase() + return s.includes('fail') || s === 'error' +} + +function isDeployRecordSuccess(status) { + const s = String(status || '').toLowerCase() + return s === 'running' || s === 'success' || s === 'succeeded' || s === 'done' || s === 'normal' +} + +async function fetchLatestDeployRecord(app, serverName) { + try { + const res = await app.cloudrun.getDeployRecords({ serverName }) + return res.DeployRecords?.[0] ?? null + } catch { + return null + } +} + +async function pollDeployUntilSettled(app, serverName, envId, baseline) { + logSection('轮询云托管状态') + log(`每 ${POLL_INTERVAL_MS / 1000}s 查询一次,最长 ${Math.round(POLL_TIMEOUT_MS / 60000)} 分钟`, 'info') + log('提示:Docker 构建阶段控制台有明细,服务 API 状态可能长时间保持 normal', 'info') + printConsoleLink(envId, serverName) + + const preUpdateTime = baseline?.updateTime || '' + const preStatus = baseline?.status || '' + const started = Date.now() + let lastStatus = '' + let lastUpdateTime = '' + let lastRecordStatus = '' + let sawInProgress = false + let warnedStuckNormal = false + + while (Date.now() - started < POLL_TIMEOUT_MS) { + const latestRecord = await fetchLatestDeployRecord(app, serverName) + if (latestRecord?.Status && latestRecord.Status !== lastRecordStatus) { + lastRecordStatus = latestRecord.Status + log( + `部署记录:${latestRecord.Status}${latestRecord.DeployTime ? `(${latestRecord.DeployTime})` : ''}`, + 'info', + ) + if (isDeployRecordFailed(latestRecord.Status)) { + return { ok: false, detail: null, accessUrl: '' } + } + if (isDeployRecordSuccess(latestRecord.Status)) { + const detail = await fetchServerDetail(app, serverName).catch(() => null) + const accessUrl = detail ? pickAccessUrl(detail) : '' + if (accessUrl) maybeWritebackAskUserBaseUrl(accessUrl) + return { ok: true, detail, accessUrl } + } + } + + let detail + try { + detail = await fetchServerDetail(app, serverName) + } catch (err) { + log(`查询服务状态失败:${err.message}`, 'warn') + await sleep(POLL_INTERVAL_MS) + continue + } + + const base = detail.BaseInfo || {} + const { Status, UpdateTime } = base + const accessUrl = pickAccessUrl(detail) + + if (Status !== lastStatus || UpdateTime !== lastUpdateTime) { + const extra = accessUrl ? ` · ${accessUrl}` : '' + log(`服务状态:${Status || 'unknown'}${UpdateTime ? `(${UpdateTime})` : ''}${extra}`, 'info') + lastStatus = Status + lastUpdateTime = UpdateTime + } + + if (accessUrl) { + maybeWritebackAskUserBaseUrl(accessUrl) + } + + if (isDeployInProgressStatus(Status) && Status !== preStatus) { + sawInProgress = true + } + + if (isDeployFailedStatus(Status)) { + return { ok: false, detail, accessUrl } + } + + if (isDeploySettledStatus(Status)) { + const updateChanged = Boolean(UpdateTime && UpdateTime !== preUpdateTime) + const noBaseline = !preUpdateTime && !preStatus + if (noBaseline || sawInProgress || updateChanged) { + return { ok: true, detail, accessUrl } + } + if (!warnedStuckNormal && Date.now() - started > 90_000) { + warnedStuckNormal = true + log('服务状态未变化:构建可能仍在进行,请以控制台构建记录为准', 'warn') + } + } + + await sleep(POLL_INTERVAL_MS) + } + + throw new Error('轮询超时:请到控制台查看构建是否仍在进行') +} + async function syncCloudRunEnv(app, envId, serverName, runtimeEnv) { const keys = Object.keys(runtimeEnv) if (keys.length === 0) { @@ -90,18 +308,14 @@ async function syncCloudRunEnv(app, envId, serverName, runtimeEnv) { logSection('同步云托管环境变量') log(`从 .env.cloud 写入 ${keys.length} 个变量到服务 ${serverName}`, 'info') - const tcbr = app.commonService('tcbr') + const tcbr = app.commonService('tcbr', TCBR_API_VERSION) await tcbr.call({ Action: 'UpdateCloudRunServer', Param: { EnvId: envId, ServerName: serverName, - Items: [ - { - Key: 'EnvParams', - Value: JSON.stringify(runtimeEnv), - }, - ], + DeployInfo: { ReleaseType: 'FULL', DeployType: 'config' }, + Items: [{ Key: 'EnvParam', Value: JSON.stringify(runtimeEnv) }], }, }) log('云托管环境变量已更新(新实例生效)', 'success') @@ -109,8 +323,6 @@ async function syncCloudRunEnv(app, envId, serverName, runtimeEnv) { } async function deployCloudRun(deployEnv, options) { - logSection('部署到云托管(容器服务)') - const envId = deployEnv.TCB_ENV_ID if (!envId) { log('缺少 TCB_ENV_ID,请先运行 ./init.sh', 'error') @@ -122,31 +334,69 @@ async function deployCloudRun(deployEnv, options) { process.exit(1) } + logSection('部署到云托管(容器服务)') + printConsoleLink(envId, DEFAULT_SERVICE_NAME) + + const app = createCloudBaseApp(deployEnv) + let preDetail + let deployBaseline = { status: '', updateTime: '' } + try { + preDetail = await fetchServerDetail(app, DEFAULT_SERVICE_NAME) + deployBaseline = { + status: preDetail.BaseInfo?.Status || '', + updateTime: preDetail.BaseInfo?.UpdateTime || '', + } + const preUrl = pickAccessUrl(preDetail) + if (preUrl) { + log(`当前默认域名:${preUrl}`, 'info') + maybeWritebackAskUserBaseUrl(preUrl) + } + } catch { + /* service may not exist yet */ + } + const rcBackup = existsSync(CLOUDBASERC) ? readFileSync(CLOUDBASERC, 'utf-8') : null writeFileSync(CLOUDBASERC, JSON.stringify({ envId }, null, 2)) try { - log('提交到云托管(云端构建)...') - run(`cloudbase cloudrun deploy -s ${DEFAULT_SERVICE_NAME} --port 80 --force --source .`) - } catch { - log('部署失败', 'error') - log(`控制台:https://tcb.cloud.tencent.com/dev?envId=${envId}#/run`, 'info') + log('提交源码到云托管(云端 Docker 构建)…', 'info') + await runCloudRunDeploy(DEFAULT_SERVICE_NAME) + log('源码已提交,云端开始构建', 'success') + } catch (err) { + log('部署提交失败', 'error') + console.error(err.message || err) + printConsoleLink(envId, DEFAULT_SERVICE_NAME) process.exit(1) } finally { if (rcBackup) writeFileSync(CLOUDBASERC, rcBackup) } - let accessUrl = '' - const app = createCloudBaseApp(deployEnv) - try { - const tcbr = app.commonService('tcbr') - const result = await tcbr.call({ - Action: 'DescribeCloudRunServerDetail', - Param: { EnvId: envId, ServerName: DEFAULT_SERVICE_NAME }, - }) - accessUrl = result.BaseInfo?.DefaultDomainName || '' - } catch { - /* optional */ + let accessUrl = pickAccessUrl(preDetail) + let pollOk = true + + if (!options.noWait) { + try { + const poll = await pollDeployUntilSettled(app, DEFAULT_SERVICE_NAME, envId, deployBaseline) + pollOk = poll.ok + accessUrl = poll.accessUrl || accessUrl + if (!poll.ok) { + log('云端报告部署失败,请打开控制台查看构建日志', 'error') + } + } catch (err) { + log(err.message, 'warn') + } + } else { + log('已跳过状态轮询(--no-wait)', 'info') + try { + const detail = await fetchServerDetail(app, DEFAULT_SERVICE_NAME) + accessUrl = pickAccessUrl(detail) || accessUrl + } catch { + /* optional */ + } + } + + if (accessUrl) { + maybeWritebackAskUserBaseUrl(accessUrl) } if (!options.skipEnvSync) { @@ -158,33 +408,35 @@ async function deployCloudRun(deployEnv, options) { const normalized = normalizeAskUserBaseUrl(accessUrl) runtimeEnv.ASK_USER_BASE_URL = normalized saveEnvVar(ENV_CLOUD, 'ASK_USER_BASE_URL', normalized) - log('已从云托管默认域名写回 .env.cloud 的 ASK_USER_BASE_URL', 'success') } try { await syncCloudRunEnv(app, envId, DEFAULT_SERVICE_NAME, runtimeEnv) } catch (err) { log('环境变量 API 同步失败,请在控制台 → 云托管 → 服务配置 粘贴 .env.cloud', 'warn') - console.error(err) + console.error(err.message || err) } } } console.log('') - log('部署已提交,云端构建中...', 'success') + if (pollOk && !options.noWait) { + log('部署流程结束(服务状态已就绪或已为 normal/running)', 'success') + } else if (options.noWait) { + log('部署已提交,请在控制台查看构建进度', 'success') + } else { + log('部署已提交,但服务未确认成功,请检查控制台', 'warn') + } console.log('') console.log(` ${colors.bright}服务:${colors.reset}${DEFAULT_SERVICE_NAME}`) + console.log(` ${colors.bright}容器端口:${colors.reset}80(云托管对外;沙箱 TRW 用 9000,勿改服务端口)`) if (accessUrl) { console.log(` ${colors.bright}访问地址:${colors.reset}${accessUrl}`) } else if (existsSync(ENV_CLOUD) && isAskUserBaseUrlUnset(loadEnvFile(ENV_CLOUD).ASK_USER_BASE_URL)) { console.log( - ` ${colors.yellow}ASK_USER_BASE_URL 仍为占位:部署完成后到控制台复制默认域名写入 .env.cloud 再执行 deploy:cloud${colors.reset}`, + ` ${colors.yellow}ASK_USER_BASE_URL 仍为占位:从控制台复制 *.sh.run.tcloudbase.com 域名后写入 .env.cloud 再部署${colors.reset}`, ) } - console.log(` ${colors.bright}构建进度:${colors.reset}`) - console.log( - ` https://tcb.cloud.tencent.com/dev?envId=${envId}#/platform-run/service/detail?serverName=${DEFAULT_SERVICE_NAME}&tabId=deploy&envId=${envId}`, - ) - console.log('') + printConsoleLink(envId, DEFAULT_SERVICE_NAME) } async function main() { @@ -194,6 +446,7 @@ async function main() { const args = process.argv.slice(2) const skipEnvSync = args.includes('--skip-env-sync') + const noWait = args.includes('--no-wait') const deployEnv = loadEnvFile(ENV_CLOUD) if (!existsSync(ENV_CLOUD)) { @@ -209,7 +462,7 @@ async function main() { process.exit(1) } - await deployCloudRun(deployEnv, { skipEnvSync }) + await deployCloudRun(deployEnv, { skipEnvSync, noWait }) } main().catch((err) => { From 228924839ec9128fe6460a3b169e0f3c0a7d3b69 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Wed, 27 May 2026 19:47:24 +0800 Subject: [PATCH 24/29] fix(server): use ESM-safe manager-node import for AGS sandbox API Centralize CloudService loading via lib/utils/index.js to fix ERR_UNSUPPORTED_DIR_IMPORT when creating sandboxes on CloudRun Node 22. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../server/scripts/debug-start-instance.ts | 20 +----- .../server/scripts/describe-stateful-tool.ts | 22 +------ .../server/scripts/stop-stateful-instances.ts | 29 ++------- .../scripts/update-stateful-tool-image.ts | 24 +------ .../scripts/update-stateful-tool-ports.ts | 22 +------ packages/server/src/lib/cloudbase-ags-api.ts | 64 +++++++++++++++++++ .../src/sandbox/ensure-stateful-tool.ts | 21 +----- .../src/sandbox/provider/stateful-provider.ts | 11 +--- 8 files changed, 82 insertions(+), 131 deletions(-) create mode 100644 packages/server/src/lib/cloudbase-ags-api.ts diff --git a/packages/server/scripts/debug-start-instance.ts b/packages/server/scripts/debug-start-instance.ts index b2ac5d2..3c66e76 100644 --- a/packages/server/scripts/debug-start-instance.ts +++ b/packages/server/scripts/debug-start-instance.ts @@ -12,29 +12,13 @@ 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<string, unknown>) { - const managerModule = await import('@cloudbase/manager-node') - const managerUtilsModule = await import('@cloudbase/manager-node/lib/utils') - const CloudBase = ((managerModule as { default?: unknown }).default || managerModule) as new (cfg: object) => { - context: object - } - const CloudService = ((managerUtilsModule as { CloudService?: unknown; default?: { CloudService?: unknown } }) - .CloudService || (managerUtilsModule as { default?: { CloudService?: unknown } }).default?.CloudService) as new ( - ctx: object, - svc: string, - ver: string, - ) => { request: (a: string, p: object) => Promise<unknown> } - - const secretId = process.env.TCB_SECRET_ID || '' - const secretKey = process.env.TCB_SECRET_KEY || '' - const envId = process.env.TCB_ENV_ID || '' - const app = new CloudBase({ secretId, secretKey, envId }) - const ags = new CloudService(app.context, 'ags', '2025-09-20') - return ags.request(action, param) + return callAgsManagerApi(action, param, agsCredentialsFromProcessEnv()) } async function main() { diff --git a/packages/server/scripts/describe-stateful-tool.ts b/packages/server/scripts/describe-stateful-tool.ts index 96681b6..f8b6d90 100644 --- a/packages/server/scripts/describe-stateful-tool.ts +++ b/packages/server/scripts/describe-stateful-tool.ts @@ -7,31 +7,13 @@ 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<string, unknown>) { - const managerModule = await import('@cloudbase/manager-node') - const managerUtilsModule = await import('@cloudbase/manager-node/lib/utils') - const CloudBase = ((managerModule as { default?: unknown }).default || managerModule) as new (cfg: object) => { - context: object - } - const CloudService = ((managerUtilsModule as { CloudService?: unknown; default?: { CloudService?: unknown } }) - .CloudService || (managerUtilsModule as { default?: { CloudService?: unknown } }).default?.CloudService) as new ( - ctx: object, - svc: string, - ver: string, - ) => { request: (a: string, p: object) => Promise<Record<string, unknown>> } - - const secretId = process.env.TCB_SECRET_ID || '' - const secretKey = process.env.TCB_SECRET_KEY || '' - const envId = process.env.TCB_ENV_ID || '' - if (!secretId || !secretKey || !envId) throw new Error('TCB_ENV_ID / TCB_SECRET_ID / TCB_SECRET_KEY required') - - const app = new CloudBase({ secretId, secretKey, envId }) - const ags = new CloudService(app.context, 'ags', '2025-09-20') - return ags.request(action, param) + return callAgsManagerApi(action, param, agsCredentialsFromProcessEnv()) } async function main() { diff --git a/packages/server/scripts/stop-stateful-instances.ts b/packages/server/scripts/stop-stateful-instances.ts index b17ff17..fc87640 100644 --- a/packages/server/scripts/stop-stateful-instances.ts +++ b/packages/server/scripts/stop-stateful-instances.ts @@ -7,6 +7,7 @@ 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') @@ -24,26 +25,8 @@ if (existsSync(envPath)) { const ACTIVE = new Set(['RUNNING', 'PAUSED', 'RESUME_FAILED']) -async function callAgsManagerApi(action: string, param: Record<string, unknown>) { - const managerModule = await import('@cloudbase/manager-node') - // @ts-expect-error manager-node ships utils without types - const managerUtilsModule = await import('@cloudbase/manager-node/lib/utils') - const CloudBase = ((managerModule as any).default || managerModule) as any - const CloudService = ((managerUtilsModule as any).CloudService || - (managerUtilsModule as any).default?.CloudService) as any - - const secretId = process.env.TCB_SECRET_ID || '' - const secretKey = process.env.TCB_SECRET_KEY || '' - const token = process.env.TCB_TOKEN || process.env.TENCENTCLOUD_SESSIONTOKEN || '' - const managerEnvId = process.env.TCB_ENV_ID || '' - - if (!secretId || !secretKey || !managerEnvId) { - throw new Error('TCB_ENV_ID and TCB_SECRET_ID/KEY are required') - } - - const app = new CloudBase({ secretId, secretKey, token, envId: managerEnvId }) - const agsService = new CloudService(app.context, 'ags', '2025-09-20') - return (await agsService.request(action, param)) as Record<string, unknown> +async function ags(action: string, param: Record<string, unknown>) { + return callAgsManagerApi(action, param, agsCredentialsFromProcessEnv()) } async function resolveToolId(): Promise<string> { @@ -57,7 +40,7 @@ async function resolveToolId(): Promise<string> { const { statefulToolNameForEnv } = await import('../src/sandbox/ensure-stateful-tool.js') const toolName = statefulToolNameForEnv(envId) - const resp = await callAgsManagerApi('DescribeSandboxToolList', { + const resp = await ags('DescribeSandboxToolList', { Filters: [{ Name: 'ToolName', Values: [toolName] }], Limit: 20, }) @@ -73,7 +56,7 @@ async function resolveToolId(): Promise<string> { async function main() { const toolId = await resolveToolId() - const list = await callAgsManagerApi('DescribeSandboxInstanceList', { ToolId: toolId, Limit: 100 }) + const list = await ags('DescribeSandboxInstanceList', { ToolId: toolId, Limit: 100 }) const data = list?.data as Record<string, unknown> | undefined const rows = (list?.InstanceSet || data?.InstanceSet || []) as Array<Record<string, unknown>> const active = rows @@ -92,7 +75,7 @@ async function main() { const errors: Array<{ instanceId: string; error: string }> = [] for (const { instanceId, status } of active) { try { - await callAgsManagerApi('StopSandboxInstance', { InstanceId: instanceId }) + await ags('StopSandboxInstance', { InstanceId: instanceId }) stopped.push(`${instanceId}(${status})`) } catch (e) { errors.push({ instanceId, error: (e as Error).message }) diff --git a/packages/server/scripts/update-stateful-tool-image.ts b/packages/server/scripts/update-stateful-tool-image.ts index 4e64109..c557a0d 100644 --- a/packages/server/scripts/update-stateful-tool-image.ts +++ b/packages/server/scripts/update-stateful-tool-image.ts @@ -9,33 +9,13 @@ 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<string, unknown>) { - const managerModule = await import('@cloudbase/manager-node') - const managerUtilsModule = await import('@cloudbase/manager-node/lib/utils') - const CloudBase = ((managerModule as { default?: unknown }).default || managerModule) as new (cfg: object) => { - context: object - } - const CloudService = ((managerUtilsModule as { CloudService?: unknown; default?: { CloudService?: unknown } }) - .CloudService || (managerUtilsModule as { default?: { CloudService?: unknown } }).default?.CloudService) as new ( - ctx: object, - svc: string, - ver: string, - ) => { request: (a: string, p: object) => Promise<unknown> } - - const secretId = process.env.TCB_SECRET_ID || '' - const secretKey = process.env.TCB_SECRET_KEY || '' - const envId = process.env.TCB_ENV_ID || '' - if (!secretId || !secretKey || !envId) { - throw new Error('TCB_ENV_ID / TCB_SECRET_ID / TCB_SECRET_KEY required') - } - - const app = new CloudBase({ secretId, secretKey, envId }) - const ags = new CloudService(app.context, 'ags', '2025-09-20') - return ags.request(action, param) + return callAgsManagerApi(action, param, agsCredentialsFromProcessEnv()) } async function main() { diff --git a/packages/server/scripts/update-stateful-tool-ports.ts b/packages/server/scripts/update-stateful-tool-ports.ts index 28da07e..fd9bfa8 100644 --- a/packages/server/scripts/update-stateful-tool-ports.ts +++ b/packages/server/scripts/update-stateful-tool-ports.ts @@ -7,6 +7,7 @@ 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') }) @@ -18,26 +19,7 @@ const STANDARD_TOOL_PORTS = [ ] async function callAgs(action: string, param: Record<string, unknown>) { - const managerModule = await import('@cloudbase/manager-node') - const managerUtilsModule = await import('@cloudbase/manager-node/lib/utils') - const CloudBase = ((managerModule as { default?: unknown }).default || managerModule) as new (cfg: object) => { - context: object - } - const CloudService = ((managerUtilsModule as { CloudService?: unknown; default?: { CloudService?: unknown } }) - .CloudService || (managerUtilsModule as { default?: { CloudService?: unknown } }).default?.CloudService) as new ( - ctx: object, - svc: string, - ver: string, - ) => { request: (a: string, p: object) => Promise<unknown> } - - const secretId = process.env.TCB_SECRET_ID || '' - const secretKey = process.env.TCB_SECRET_KEY || '' - const envId = process.env.TCB_ENV_ID || '' - if (!secretId || !secretKey || !envId) throw new Error('TCB_ENV_ID / TCB_SECRET_ID / TCB_SECRET_KEY required') - - const app = new CloudBase({ secretId, secretKey, envId }) - const ags = new CloudService(app.context, 'ags', '2025-09-20') - return ags.request(action, param) + return callAgsManagerApi(action, param, agsCredentialsFromProcessEnv()) } async function resolveToolId(): Promise<string> { 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<string, unknown>) => Promise<unknown> +} + +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<string, unknown>, + creds: AgsManagerCredentials, +): Promise<Record<string, unknown>> { + 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<string, unknown> +} + +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/sandbox/ensure-stateful-tool.ts b/packages/server/src/sandbox/ensure-stateful-tool.ts index db414ee..a5e66a5 100644 --- a/packages/server/src/sandbox/ensure-stateful-tool.ts +++ b/packages/server/src/sandbox/ensure-stateful-tool.ts @@ -13,6 +13,7 @@ import { 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' @@ -71,25 +72,7 @@ function resolveSandboxGatewayUrl(envId: string): string { } async function callAgsManagerApi(action: string, param: Record<string, unknown>): Promise<Record<string, unknown>> { - const managerModule = await import('@cloudbase/manager-node') - // @ts-expect-error manager-node ships utils without types - const managerUtilsModule = await import('@cloudbase/manager-node/lib/utils') - const CloudBase = ((managerModule as any).default || managerModule) as any - const CloudService = ((managerUtilsModule as any).CloudService || - (managerUtilsModule as any).default?.CloudService) as any - - const secretId = process.env.TCB_SECRET_ID || '' - const secretKey = process.env.TCB_SECRET_KEY || '' - const token = process.env.TCB_TOKEN || process.env.TENCENTCLOUD_SESSIONTOKEN || '' - const managerEnvId = process.env.TCB_ENV_ID || '' - - if (!secretId || !secretKey || !managerEnvId) { - throw new Error('TCB_ENV_ID and TCB_SECRET_ID/KEY are required to manage sandbox tools') - } - - const app = new CloudBase({ secretId, secretKey, token, envId: managerEnvId }) - const agsService = new CloudService(app.context, 'ags', '2025-09-20') - return (await agsService.request(action, param)) as Record<string, unknown> + return requestAgsManagerApi(action, param, agsCredentialsFromProcessEnv()) } async function createSandboxTool(envId: string): Promise<string> { diff --git a/packages/server/src/sandbox/provider/stateful-provider.ts b/packages/server/src/sandbox/provider/stateful-provider.ts index 29b13a4..5a51d3a 100644 --- a/packages/server/src/sandbox/provider/stateful-provider.ts +++ b/packages/server/src/sandbox/provider/stateful-provider.ts @@ -40,6 +40,7 @@ import type { } from './types.js' import { STATEFUL_WORKSPACE_ROOT } from '../../lib/sandbox-config.js' import { buildGitArchiveWorkspaceEnv, injectGitArchiveWorkspaceEnv } 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 { @@ -220,20 +221,12 @@ async function callAgsManagerApi( param: Record<string, unknown>, cfg: StatefulRuntimeConfig, ): Promise<Record<string, unknown>> { - const managerModule = await import('@cloudbase/manager-node') - // @ts-expect-error manager-node ships utils without types - const managerUtilsModule = await import('@cloudbase/manager-node/lib/utils') - const CloudBase = ((managerModule as any).default || managerModule) as any - const CloudService = ((managerUtilsModule as any).CloudService || - (managerUtilsModule as any).default?.CloudService) as any - const app = new CloudBase({ + return requestAgsManagerApi(action, param, { secretId: cfg.managerSecretId, secretKey: cfg.managerSecretKey, token: cfg.managerToken, envId: cfg.managerEnvId, }) - const agsService = new CloudService(app.context, 'ags', '2025-09-20') - return agsService.request(action, param) } async function startStatefulInstance(cfg: StatefulRuntimeConfig, toolId: string): Promise<string> { From 87db2c85b9b1dd844545cfb86b45fb23c0078de9 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Wed, 27 May 2026 20:14:47 +0800 Subject: [PATCH 25/29] fix(server): MCP policies, git archive env, and mcporter schema parse Register CloudBase MCP policies statically in bundled dist, route git archive vars through workspace/init only, parse mcporter schema without stderr pollution, and log upstream WS upgrade HTTP status for terminal debug. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../__tests__/loader-artifacts.test.ts | 15 +++++++++ .../server/src/lib/mcp-middleware/loader.ts | 19 ++++++++++++ .../src/middleware/mcp/cloudbase/_index.ts | 28 ++++++++++++++--- .../__tests__/mcporter-schema-parse.test.ts | 19 ++++++++++++ packages/server/src/sandbox/git-archive.ts | 31 ++++++------------- .../server/src/sandbox/preview-ws-proxy.ts | 1 + .../src/sandbox/provider/stateful-provider.ts | 10 ++---- .../sandbox/stateful/stateful-mcp-client.ts | 20 ++++++++---- 8 files changed, 103 insertions(+), 40 deletions(-) create mode 100644 packages/server/src/lib/mcp-middleware/__tests__/loader-artifacts.test.ts create mode 100644 packages/server/src/sandbox/__tests__/mcporter-schema-parse.test.ts 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<Extra = Record<string, unknown>>( /** 扫描目录的绝对路径(一般是 path.dirname(fileURLToPath(import.meta.url))) */ dir: string, @@ -54,6 +62,8 @@ export function createPolicyLoader<Extra = Record<string, unknown>>( logTag: string = 'mcp-policies', /** 全局过滤配置 */ options: PolicyLoaderOptions = {}, + /** When set (recommended for bundled production), skip filesystem scan entirely. */ + staticPolicies?: Record<string, McpPolicy<Extra>>, ): PolicyLoader<Extra> { const policyMap = new Map<string, McpPolicy<Extra>>() let loaded = false @@ -92,6 +102,14 @@ export function createPolicyLoader<Extra = Record<string, unknown>>( 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<Extra = Record<string, unknown>>( 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/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<CloudbaseExtra>(__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<string, McpPolicy> = { + auth: authPolicy, + cronTask: cronTaskPolicy, + downloadTemplate: downloadTemplatePolicy, + getDeployJobStatus: getDeployJobStatusPolicy, + publishMiniprogram: publishMiniprogramPolicy, + uploadFiles: uploadFilesPolicy, +} + +const loader = createPolicyLoader<CloudbaseExtra>( + __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/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/git-archive.ts b/packages/server/src/sandbox/git-archive.ts index dce06c9..91cf5f0 100644 --- a/packages/server/src/sandbox/git-archive.ts +++ b/packages/server/src/sandbox/git-archive.ts @@ -66,11 +66,12 @@ export function isGitArchiveConfigured(): boolean { } /** - * 沙箱业务镜像 git archive vars for PUT /api/workspace/env or workspace/init `env`. + * 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 buildGitArchiveWorkspaceEnv(): Record<string, string> { +export function buildGitArchiveInitEnv(): Record<string, string> { const env: Record<string, string> = {} const repo = process.env.GIT_ARCHIVE_REPO?.trim() const token = process.env.GIT_ARCHIVE_TOKEN?.trim() @@ -84,28 +85,14 @@ export function buildGitArchiveWorkspaceEnv(): Record<string, string> { return { ...env, ...buildStatefulWorkspaceAuthEnv() } } -/** Debug-only: AGS CustomConfiguration.Env array shape. */ -export function buildGitArchiveInstanceEnv(): Array<{ Name: string; Value: string }> { - return Object.entries(buildGitArchiveWorkspaceEnv()).map(([Name, Value]) => ({ Name, Value })) +/** @deprecated Use {@link buildGitArchiveInitEnv} — name kept for callers. */ +export function buildGitArchiveWorkspaceEnv(): Record<string, string> { + return buildGitArchiveInitEnv() } -export async function injectGitArchiveWorkspaceEnv(sandbox: SandboxInstance): Promise<void> { - const env = buildGitArchiveWorkspaceEnv() - if (!Object.keys(env).length) return - - const res = await sandbox.request('/api/workspace/env', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(env), - }) - if (!res.ok) { - const text = await res.text().catch(() => '') - throw new Error(`Git archive workspace env injection failed: ${res.status} ${text.slice(0, 200)}`) - } - const data = (await res.json().catch(() => null)) as { success?: boolean; error?: string } | null - if (data && data.success === false) { - throw new Error(data.error || 'Git archive workspace env injection failed') - } +/** Debug-only: AGS CustomConfiguration.Env array shape. */ +export function buildGitArchiveInstanceEnv(): Array<{ Name: string; Value: string }> { + return Object.entries(buildGitArchiveInitEnv()).map(([Name, Value]) => ({ Name, Value })) } /** diff --git a/packages/server/src/sandbox/preview-ws-proxy.ts b/packages/server/src/sandbox/preview-ws-proxy.ts index 9a7b72b..39a5c46 100644 --- a/packages/server/src/sandbox/preview-ws-proxy.ts +++ b/packages/server/src/sandbox/preview-ws-proxy.ts @@ -186,6 +186,7 @@ export function attachPreviewWebSocketProxy(server: Server): void { 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`) } diff --git a/packages/server/src/sandbox/provider/stateful-provider.ts b/packages/server/src/sandbox/provider/stateful-provider.ts index 5a51d3a..e07c866 100644 --- a/packages/server/src/sandbox/provider/stateful-provider.ts +++ b/packages/server/src/sandbox/provider/stateful-provider.ts @@ -39,7 +39,7 @@ import type { ToolOverrideHosting, } from './types.js' import { STATEFUL_WORKSPACE_ROOT } from '../../lib/sandbox-config.js' -import { buildGitArchiveWorkspaceEnv, injectGitArchiveWorkspaceEnv } from '../git-archive.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' @@ -464,12 +464,6 @@ class StatefulProvider implements SandboxProvider { cacheKey: key, }) - try { - await injectGitArchiveWorkspaceEnv(inst) - } catch (err) { - console.warn('[StatefulProvider] Git archive workspace env injection failed:', (err as Error).message) - } - this.instanceCache.set(key, inst) onProgress?.({ phase: 'ready', message: '沙箱已就绪\n' }) return inst @@ -500,7 +494,7 @@ class StatefulProvider implements SandboxProvider { ...(ctx.credentials.sessionToken ? { TENCENTCLOUD_SESSIONTOKEN: ctx.credentials.sessionToken } : {}), INTEGRATION_IDE: 'codebuddy', WORKSPACE_FOLDER_PATHS: ctx.workspaceHint || STATEFUL_WORKSPACE_ROOT, - ...buildGitArchiveWorkspaceEnv(), + ...buildGitArchiveInitEnv(), }, }), signal: AbortSignal.timeout(PREPARE_INIT_TIMEOUT_MS), diff --git a/packages/server/src/sandbox/stateful/stateful-mcp-client.ts b/packages/server/src/sandbox/stateful/stateful-mcp-client.ts index 5e2960e..77b5458 100644 --- a/packages/server/src/sandbox/stateful/stateful-mcp-client.ts +++ b/packages/server/src/sandbox/stateful/stateful-mcp-client.ts @@ -20,7 +20,6 @@ import { nanoid } from 'nanoid' import cron from 'node-cron' import type { McpClientBundle, McpDeps } from '../provider/types.js' -import { buildGitArchiveWorkspaceEnv } from '../git-archive.js' import { miniprogramStartToJson, pollTrwMiniprogramJob, @@ -39,6 +38,17 @@ class AuthRequiredError extends Error { } } +/** 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<string, z.ZodTypeAny> { @@ -249,7 +259,6 @@ export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientB TENCENTCLOUD_SESSIONTOKEN: creds.sessionToken ?? '', INTEGRATION_IDE: 'codebuddy', WORKSPACE_FOLDER_PATHS: workspaceFolderPaths, - ...buildGitArchiveWorkspaceEnv(), }), }) if (res.status === 401 || res.status === 403) throw new AuthRequiredError(res.status) @@ -259,7 +268,7 @@ export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientB async function fetchCloudbaseSchema(): Promise<any[]> { const tmpPath = `.mcporter-schema.json` - await bashCall(`mcporter list cloudbase --schema --output json > ${tmpPath} 2>&1`, 20_000) + 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' }, @@ -268,9 +277,8 @@ export async function createStatefulMcpClient(deps: McpDeps): Promise<McpClientB 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 parsed = JSON.parse(data.result.content) as any - if (!Array.isArray(parsed.tools)) throw new Error('No tools array in schema response') - return parsed.tools + const { tools } = parseMcporterSchemaContent(data.result.content) + return tools as any[] } async function mcporterCall(toolName: string, args: Record<string, unknown>): Promise<any> { From 8af240f848546f3a01a812260a29544dbcb83cf9 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Wed, 27 May 2026 20:24:34 +0800 Subject: [PATCH 26/29] fix(server): stop forwarding browser Origin on preview WebSocket proxy Gateway returned 403 on WS upgrade when OVC forwarded CloudRun Origin to tcloudbasegateway; upstream now sends sandbox auth headers only. Co-authored-by: Cursor <cursoragent@cursor.com> --- .../__tests__/preview-ws-proxy.test.ts | 18 +++++++- .../server/src/sandbox/preview-ws-proxy.ts | 41 ++++--------------- 2 files changed, 25 insertions(+), 34 deletions(-) diff --git a/packages/server/src/sandbox/__tests__/preview-ws-proxy.test.ts b/packages/server/src/sandbox/__tests__/preview-ws-proxy.test.ts index 056f0bf..3ca82de 100644 --- a/packages/server/src/sandbox/__tests__/preview-ws-proxy.test.ts +++ b/packages/server/src/sandbox/__tests__/preview-ws-proxy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildGatewayWebSocketUrl, parseClientWsProtocols } from '../preview-ws-proxy.js' +import { buildGatewayWebSocketUrl, buildUpstreamGatewayWsHeaders, parseClientWsProtocols } from '../preview-ws-proxy.js' describe('buildGatewayWebSocketUrl', () => { it('includes gateway path prefix before /preview/{port}/ws', () => { @@ -17,6 +17,22 @@ describe('buildGatewayWebSocketUrl', () => { }) }) +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({ diff --git a/packages/server/src/sandbox/preview-ws-proxy.ts b/packages/server/src/sandbox/preview-ws-proxy.ts index 39a5c46..d40b481 100644 --- a/packages/server/src/sandbox/preview-ws-proxy.ts +++ b/packages/server/src/sandbox/preview-ws-proxy.ts @@ -11,28 +11,6 @@ import { resolveGatewayPreviewPort } from './ttyd-gateway-port.js' const PREVIEW_WS_RE = /^\/api\/tasks\/([^/]+)\/preview\/(\d+)(\/.*)?$/ -const HOP_BY_HOP = new Set([ - 'connection', - 'keep-alive', - 'proxy-authenticate', - 'proxy-authorization', - 'te', - 'trailers', - 'transfer-encoding', - 'upgrade', - 'host', - 'cookie', - 'content-length', -]) - -/** Client→OVC handshake headers must not be forwarded; upstream `ws` generates its own. */ -const CLIENT_WS_HANDSHAKE = new Set([ - 'sec-websocket-key', - 'sec-websocket-version', - 'sec-websocket-extensions', - 'sec-websocket-protocol', -]) - const wss = new WebSocketServer({ noServer: true }) function buildUpstreamPath(port: string, subpath: string | undefined): string { @@ -49,15 +27,12 @@ export function buildGatewayWebSocketUrl(baseUrl: string, previewPath: string, q return `${wsProtocol}//${base.host}${prefix}${normalized}${query}` } -function buildForwardHeaders(req: IncomingMessage, authHeaders: Record<string, string>): Record<string, string> { - const out: Record<string, string> = { ...authHeaders } - for (const [key, value] of Object.entries(req.headers)) { - if (value === undefined) continue - const lower = key.toLowerCase() - if (HOP_BY_HOP.has(lower) || CLIENT_WS_HANDSHAKE.has(lower)) continue - out[key] = Array.isArray(value) ? value.join(', ') : value - } - return out +/** + * 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<string, string>): Record<string, string> { + return { ...authHeaders } } /** Browser ttyd uses Sec-WebSocket-Protocol: tty; upstream must match or ttyd closes immediately. */ @@ -164,11 +139,11 @@ export function attachPreviewWebSocketProxy(server: Server): void { 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 forwardHeaders = buildForwardHeaders(req, await sandbox.getAuthHeaders()) + const upstreamHeaders = buildUpstreamGatewayWsHeaders(await sandbox.getAuthHeaders()) const clientProtocols = parseClientWsProtocols(req) wss.handleUpgrade(req, socket, head, (clientWs) => { - const upstreamWs = connectUpstreamPreviewWebSocket(wsUrl, clientProtocols, forwardHeaders) + const upstreamWs = connectUpstreamPreviewWebSocket(wsUrl, clientProtocols, upstreamHeaders) bridgeSockets(clientWs, upstreamWs) clientWs.on('ping', (data) => { From 6022c27db1246f9f054ea3103b8c5e91072fded4 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Wed, 27 May 2026 20:43:27 +0800 Subject: [PATCH 27/29] docs: record upstream merge trial at dc70b08 Co-authored-by: Cursor <cursoragent@cursor.com> --- docs/upstream-fork.md | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/docs/upstream-fork.md b/docs/upstream-fork.md index 966f020..440cc71 100644 --- a/docs/upstream-fork.md +++ b/docs/upstream-fork.md @@ -38,8 +38,9 @@ | --- | --- | --- | --- | --- | | 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`(试跑分支 `merge-trial/main-into-stateful`) | `dc70b08d8e3019884b51a9b4ae219b7a1af8d439` | `0d4e65b56348d90e61d0a794b70ce4d5369b91b9` | 冲突:`.env.example`、`README.md`、`pnpm-lock.yaml`、`scripts/init.mjs`、`scripts/setup-tcr.mjs`;`scf-sandbox-manager.ts` 删除保留;`type-check` / `lint` / `build` 通过 | -**本次并入的上游 commit**(`43c3e60..a878ddb`): +**历史:2026-05-21 并入**(`43c3e60..a878ddb`): | SHA | 说明 | | --- | --- | @@ -49,17 +50,36 @@ | `4669043` | Merge pull request #23(CodeBuddy TokenHub) | | `a878ddb` | feat: 更新 agent 选项 | -**当前对齐状态**(2026-05-25): +**本次并入的上游 commit**(`4592517..dc70b08`,2026-05-27 试跑合并): -- `git merge-base HEAD origin/main` → `4592517`(已与上游 `main` 最新对齐) -- 本线仍在 merge-base 之上保留 stateful 提交(AGS/沙箱业务镜像、文档、`0699323` 等) +| 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-21,分支 `merge-trial/main-into-stateful`): + +- `git merge-base HEAD origin/main` → `dc70b08`(与上游 `main` 最新对齐) +- 试跑合并提交:`0d4e65b`;功能分支 `feature/stateful-infra` 仍为 `8af240f`(未 fast-forward,待回归通过后合并试跑分支) +- 本线保留: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 4592517..origin/main --oneline +git log dc70b08..origin/main --oneline ``` ## 偶尔从上游同步(推荐流程) @@ -68,7 +88,7 @@ git log 4592517..origin/main --oneline git fetch origin # 自上次对齐的顶往下看 -git log a878ddbbee2f6320395dc7f84a7e6a068c524e75..origin/main --oneline +git log dc70b08d8e3019884b51a9b4ae219b7a1af8d439..origin/main --oneline # 整分支合并(可能冲突,需人工解) git merge origin/main From 1e20ed4096407c786feee772e63fe902cdb5db51 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Wed, 27 May 2026 20:54:00 +0800 Subject: [PATCH 28/29] docs: fix upstream sync status date to 2026-05-27 Co-authored-by: Cursor <cursoragent@cursor.com> --- docs/upstream-fork.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/upstream-fork.md b/docs/upstream-fork.md index 440cc71..e23d447 100644 --- a/docs/upstream-fork.md +++ b/docs/upstream-fork.md @@ -66,7 +66,7 @@ | `24f9bba` | Merge PR #27 podman-fallback | | `dc70b08` | feat(init): TCR enterprise registry | -**当前对齐状态**(2026-05-21,分支 `merge-trial/main-into-stateful`): +**当前对齐状态**(2026-05-27,分支 `merge-trial/main-into-stateful`): - `git merge-base HEAD origin/main` → `dc70b08`(与上游 `main` 最新对齐) - 试跑合并提交:`0d4e65b`;功能分支 `feature/stateful-infra` 仍为 `8af240f`(未 fast-forward,待回归通过后合并试跑分支) From 81dcf89938a33fc9448a67578b894fbab65a1696 Mon Sep 17 00:00:00 2001 From: enzopang <enzopang@tencent.com> Date: Wed, 27 May 2026 20:54:16 +0800 Subject: [PATCH 29/29] docs: upstream sync merged into feature/stateful-infra Co-authored-by: Cursor <cursoragent@cursor.com> --- docs/upstream-fork.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/upstream-fork.md b/docs/upstream-fork.md index e23d447..82a8d29 100644 --- a/docs/upstream-fork.md +++ b/docs/upstream-fork.md @@ -38,7 +38,7 @@ | --- | --- | --- | --- | --- | | 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`(试跑分支 `merge-trial/main-into-stateful`) | `dc70b08d8e3019884b51a9b4ae219b7a1af8d439` | `0d4e65b56348d90e61d0a794b70ce4d5369b91b9` | 冲突:`.env.example`、`README.md`、`pnpm-lock.yaml`、`scripts/init.mjs`、`scripts/setup-tcr.mjs`;`scf-sandbox-manager.ts` 删除保留;`type-check` / `lint` / `build` 通过 | +| 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`): @@ -66,10 +66,10 @@ | `24f9bba` | Merge PR #27 podman-fallback | | `dc70b08` | feat(init): TCR enterprise registry | -**当前对齐状态**(2026-05-27,分支 `merge-trial/main-into-stateful`): +**当前对齐状态**(2026-05-27,分支 `feature/stateful-infra`): - `git merge-base HEAD origin/main` → `dc70b08`(与上游 `main` 最新对齐) -- 试跑合并提交:`0d4e65b`;功能分支 `feature/stateful-infra` 仍为 `8af240f`(未 fast-forward,待回归通过后合并试跑分支) +- 功能分支顶:`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`)