From 7e60e0cbc37ba9e20e9611416702750232741e30 Mon Sep 17 00:00:00 2001 From: "zhenguo.li" Date: Thu, 3 Sep 2026 22:02:57 +0800 Subject: [PATCH 1/2] feat(studio): add VeStack deployment and managed sandboxes --- .dockerignore | 14 + docker/Dockerfile.vestack | 56 ++ docker/Dockerfile.vestack-overlay | 11 + docker/README.vestack.md | 35 + docker/vendor/.gitignore | 2 + docker/vendor/.gitkeep | 0 docker/vestack-entrypoint.sh | 21 + ...-studio-hybrid-cloud-deployment-options.md | 317 ++++++++ tests/auth/test_oauth2_auth.py | 42 ++ tests/cli/test_frontend_agent_proxy.py | 160 ++++ tests/cli/test_frontend_deploy_iam.py | 21 + tests/cli/test_frontend_deploy_iam_vestack.py | 69 ++ tests/cli/test_frontend_runtime_proxy.py | 19 + tests/cli/test_frontend_sandbox.py | 709 +++++++++++++++++- tests/cli/test_frontend_sandbox_options.py | 4 +- tests/cli/test_vestack_studio_deploy.py | 379 ++++++++++ tests/cli/test_vestack_studio_image.py | 50 ++ .../frontend/server/test_agentkit_clients.py | 21 + tests/integrations/test_ve_apig_vestack.py | 227 ++++++ tests/integrations/test_ve_faas_image.py | 48 ++ veadk/auth/middleware/oauth2_auth.py | 15 +- veadk/cli/cli_frontend.py | 443 ++++++++++- veadk/cli/frontend_agent_proxy.py | 119 ++- veadk/cli/frontend_deploy_iam.py | 54 +- veadk/cli/frontend_deploy_iam_vestack.py | 92 +++ veadk/cli/frontend_sandbox.py | 524 ++++++++++++- .../frontend_sandbox_managed_tool_vestack.py | 298 ++++++++ veadk/cli/vestack_studio_deploy.py | 271 +++++++ veadk/integrations/ve_apig/ve_apig.py | 188 ++++- veadk/integrations/ve_faas/ve_faas.py | 12 +- .../ve_identity/identity_client.py | 14 +- veadk/utils/cloud_provider.py | 33 +- 32 files changed, 4194 insertions(+), 74 deletions(-) create mode 100644 .dockerignore create mode 100644 docker/Dockerfile.vestack create mode 100644 docker/Dockerfile.vestack-overlay create mode 100644 docker/README.vestack.md create mode 100644 docker/vendor/.gitignore create mode 100644 docker/vendor/.gitkeep create mode 100644 docker/vestack-entrypoint.sh create mode 100644 docs/veadk-studio-hybrid-cloud-deployment-options.md create mode 100644 tests/cli/test_frontend_deploy_iam_vestack.py create mode 100644 tests/cli/test_vestack_studio_deploy.py create mode 100644 tests/cli/test_vestack_studio_image.py create mode 100644 tests/frontend/server/test_agentkit_clients.py create mode 100644 tests/integrations/test_ve_apig_vestack.py create mode 100644 tests/integrations/test_ve_faas_image.py create mode 100644 veadk/cli/frontend_deploy_iam_vestack.py create mode 100644 veadk/cli/frontend_sandbox_managed_tool_vestack.py create mode 100644 veadk/cli/vestack_studio_deploy.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..d8e4adb59 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.github +.venv +.pytest_cache +.ruff_cache +**/__pycache__ +**/*.pyc +.env +.env.* +frontend/node_modules +frontend/coverage +frontend/.vite +dist +build diff --git a/docker/Dockerfile.vestack b/docker/Dockerfile.vestack new file mode 100644 index 000000000..236459ac9 --- /dev/null +++ b/docker/Dockerfile.vestack @@ -0,0 +1,56 @@ +# syntax is intentionally omitted: VeStack BuildKit may not reach Docker Hub. +ARG PYTHON_IMAGE=python:3.12-slim +FROM ${PYTHON_IMAGE} +ARG VEADK_INSTALL_DEPENDENCIES=1 + +WORKDIR /app +COPY . /src/veadk-python +RUN if [ "${VEADK_INSTALL_DEPENDENCIES}" = "1" ]; then \ + python -m pip install --no-cache-dir \ + --find-links=/src/veadk-python/docker/vendor \ + /src/veadk-python; \ + else \ + python -m pip install --no-cache-dir \ + --find-links=/src/veadk-python/docker/vendor \ + "agentkit-sdk-python>=0.8.0" \ + "google-adk==2.2.0" \ + "mcp==1.26.0" \ + "aiomysql==0.3.2" \ + "asyncpg>=0.29.0" \ + "deprecated==1.2.18" \ + "pydantic-settings==2.10.1" \ + "pymysql==1.1.1" \ + "pypdfium2>=4.30.0" \ + "python-frontmatter==1.1.0" \ + "trafilatura>=2.0,<2.1" \ + "trustedmcp==0.0.5" \ + "vikingdb-python-sdk>=0.1.3" \ + "volcengine-python-sdk>=5.0.36" \ + "wrapt==1.17.2" \ + && python -m pip install --no-cache-dir --no-deps \ + "litellm==1.83.14" \ + && python -m pip install --no-cache-dir --no-deps \ + /src/veadk-python/docker/vendor/fastmcp_slim-*.whl \ + /src/veadk-python/docker/vendor/fastmcp-*.whl \ + && python -m pip install --no-cache-dir --no-deps \ + /src/veadk-python/docker/vendor/openviking_sdk-*.whl \ + /src/veadk-python; \ + fi \ + && rm -rf /src/veadk-python + +COPY docker/vestack-entrypoint.sh /app/run.sh +RUN chmod 0755 /app/run.sh + +ENV PYTHONUNBUFFERED=1 \ + CLOUD_PROVIDER=volcengine \ + AGENTKIT_CLOUD_PROVIDER=volcengine \ + VOLCENGINE_AGENTKIT_HOST=top.vestack.cloud \ + VOLCENGINE_AGENTKIT_SCHEME=http \ + VOLCENGINE_AGENTKIT_REGION=cn-bj \ + VOLCENGINE_AGENTKIT_SERVICE=agentkit + +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ping', timeout=3)" || exit 1 + +ENTRYPOINT ["/app/run.sh"] diff --git a/docker/Dockerfile.vestack-overlay b/docker/Dockerfile.vestack-overlay new file mode 100644 index 000000000..6b8db7a9a --- /dev/null +++ b/docker/Dockerfile.vestack-overlay @@ -0,0 +1,11 @@ +# Incremental VeStack image used when a previously validated Studio base image +# already contains all runtime dependencies. Only runtime modules changed by +# the VeStack endpoint adaptation are replaced. +ARG BASE_IMAGE +FROM ${BASE_IMAGE} + +# Keep every current VeADK module and Studio BFF module on one source revision. +# A narrow two-file overlay can import symbols absent from an older validated +# base image even when its third-party dependency layer is still compatible. +COPY veadk /usr/local/lib/python3.12/site-packages/veadk +COPY frontend/server /usr/local/lib/python3.12/site-packages/frontend/server diff --git a/docker/README.vestack.md b/docker/README.vestack.md new file mode 100644 index 000000000..9ae476ef0 --- /dev/null +++ b/docker/README.vestack.md @@ -0,0 +1,35 @@ +# VeADK Studio on VeStack + +This image runs the Studio FastAPI BFF and React UI directly on port 8000. It is +intended for VeStack environments where the public-cloud VeFaaS Application/BFF +workflow is unavailable and a prebuilt image must be attached to a Function. + +Build for the VeFaaS data plane: + +```bash +docker build --platform linux/amd64 \ + -f docker/Dockerfile.vestack \ + --build-arg PYTHON_IMAGE= \ + -t //veadk-studio: . +``` + +If the selected VeStack base image already contains the VEADK runtime +dependencies, also pass `--build-arg VEADK_INSTALL_DEPENDENCIES=0`. This avoids +resolving the entire dependency graph against an incomplete private PyPI mirror; +the current repository and wheels staged under `docker/vendor/` are still +installed. + +Stage the wheels absent from the VeStack mirror before using that mode: + +```bash +python -m pip download --no-deps \ + 'openviking-sdk>=0.1.3' 'fastmcp==3.4.7' 'fastmcp-slim==3.4.7' \ + -d docker/vendor +``` + +The runtime defaults to the VeStack TOP endpoint and `gateway` authentication, +which expects the AgentKit/APIG gateway to authenticate requests. For a direct +browser route, set `VEADK_STUDIO_AUTH_MODE=frontend` and configure the Identity +user pool/client. +Long-lived AK/SK values must not be placed in the image or Function environment; +bind an IAM role and let VeFaaS mount rotating STS credentials instead. diff --git a/docker/vendor/.gitignore b/docker/vendor/.gitignore new file mode 100644 index 000000000..dd621615b --- /dev/null +++ b/docker/vendor/.gitignore @@ -0,0 +1,2 @@ +*.whl +!.gitkeep diff --git a/docker/vendor/.gitkeep b/docker/vendor/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/docker/vestack-entrypoint.sh b/docker/vestack-entrypoint.sh new file mode 100644 index 000000000..5960659f6 --- /dev/null +++ b/docker/vestack-entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +: "${CLOUD_PROVIDER:=volcengine}" +: "${AGENTKIT_CLOUD_PROVIDER:=${CLOUD_PROVIDER}}" +: "${VOLCENGINE_AGENTKIT_HOST:=top.vestack.cloud}" +: "${VOLCENGINE_AGENTKIT_SCHEME:=http}" +: "${VOLCENGINE_AGENTKIT_REGION:=cn-bj}" +: "${VOLCENGINE_AGENTKIT_SERVICE:=agentkit}" +: "${VEADK_STUDIO_AUTH_MODE:=gateway}" +: "${VEADK_STUDIO_LISTEN_PORT:=${_FAAS_RUNTIME_PORT:-8000}}" + +export CLOUD_PROVIDER AGENTKIT_CLOUD_PROVIDER +export VOLCENGINE_AGENTKIT_HOST VOLCENGINE_AGENTKIT_SCHEME +export VOLCENGINE_AGENTKIT_REGION VOLCENGINE_AGENTKIT_SERVICE + +exec python -m veadk.cli.cli studio \ + --provider "${CLOUD_PROVIDER}" \ + --auth-mode "${VEADK_STUDIO_AUTH_MODE}" \ + --host 0.0.0.0 \ + --port "${VEADK_STUDIO_LISTEN_PORT}" diff --git a/docs/veadk-studio-hybrid-cloud-deployment-options.md b/docs/veadk-studio-hybrid-cloud-deployment-options.md new file mode 100644 index 000000000..6c86a1785 --- /dev/null +++ b/docs/veadk-studio-hybrid-cloud-deployment-options.md @@ -0,0 +1,317 @@ +# VeADK Studio 混合云部署方案 + +## 1. 汇报摘要 + +VeADK Studio 可以在混合云落地,当前已验证的最小方案是:本地构建 Studio 镜像并推送到混合云镜像仓库,通过 VeFaaS `CreateFunction`/`Release` 创建镜像函数,再由共享 APIG Gateway 暴露独立域名。函数绑定 IAM Role 后,VeFaaS 自动向 Pod 注入并定时刷新 STS 凭证,Studio 使用临时凭证调用混合云 AgentKit OpenAPI,不需要在镜像或函数环境中保存 AK/SK。 + +POC 已验证以下链路: + +- Studio Function 发布成功,运行状态正常; +- APIG 独立域名可访问,`/ping` 返回 HTTP 200; +- Pod 内存在 IAM STS credential 文件; +- Studio 通过混合云 AgentKit OpenAPI 成功查询 26 个 Runtime; +- 函数环境仅包含非敏感的 Host、Scheme、Region 和 Service 配置; +- 未向镜像、Function 环境或日志写入 AK/SK。 + +短期建议采用 **VeFaaS Function + APIG**,以最小的平台改造交付 Studio。中期建议在 AgentKit Runtime 增加 `Studio` 类型,使 Studio 由 AgentKit 统一管理。Runtime 方案不能只增加类型枚举,还需要同时提供适合浏览器访问的 Web Endpoint、SSO/会话鉴权和健康检查能力。 + +## 2. 背景与约束 + +混合云环境具备 VeFaaS、AgentKit Runtime、镜像仓库和 APIG,但存在以下约束: + +- VeFaaS 没有独立产品入口,Function 日常管理不方便; +- 混合云没有云上镜像构建能力,镜像必须在本地或 CI 构建后推送; +- 混合云不支持公有云 VeFaaS Application 接口,只能使用 Function 接口; +- 当前 Runtime Endpoint 主要面向 API 调用,使用请求头 API Key 鉴权;浏览器直接访问时无法安全、透明地携带该请求头; +- Studio 既是 Web 前端和 BFF,也是 AgentKit 控制面客户端,需要访问 Runtime、Skill、Tool、Knowledge 等平台接口; +- 长期凭证不得进入镜像、函数环境、浏览器或日志。 + +## 3. 已验证方案:VeFaaS Function + APIG + +### 3.1 架构 + +```mermaid +flowchart LR + Browser["用户浏览器"] -->|HTTP/HTTPS| APIG["共享 APIG Gateway\n独立 Host/Route"] + APIG --> Function["VeFaaS 镜像 Function\nVeADK Studio :8000"] + Function -->|读取并自动刷新| STS["IAM Role STS 凭证"] + Function -->|TOP 签名请求| AgentKit["混合云 AgentKit OpenAPI"] + Builder["Mac 或 CI\nlinux/amd64 构建"] --> CR["混合云镜像仓库"] + CR --> Function +``` + +### 3.2 部署链路 + +1. 在 Mac 或 CI 使用 Docker 构建 `linux/amd64` Studio 镜像。 +2. 将镜像推送到混合云镜像仓库。 +3. 在混合云 IAM 创建 Studio 专用 Role 和自定义 Policy,信任主体为 VeFaaS。 +4. 调用 VeFaaS `CreateFunction`,设置镜像地址、启动命令、端口、Role 和非敏感环境变量。 +5. 调用 VeFaaS `Release`,等待 Function 发布完成。 +6. 在共享 APIG Gateway 创建 Service、Upstream 和 Host Route,将独立域名转发到 Function。 +7. VeFaaS 在 Pod 内挂载 Role 的临时 STS credential,并负责定时刷新。 +8. Studio 使用 STS credential 对 AgentKit TOP 请求签名,读取和管理 AgentKit 资源。 + +### 3.3 调用链路 + +```mermaid +sequenceDiagram + participant U as Browser + participant G as APIG + participant S as Studio Function + participant I as IAM/STS + participant A as AgentKit OpenAPI + + U->>G: GET / 或 /web/runtimes + G->>S: 转发 HTTP 请求 + S->>I: 读取 VeFaaS 挂载的临时凭证 + I-->>S: AK/SK/Token(定时刷新) + S->>A: TOP 签名 ListRuntimes + A-->>S: Runtime 列表 + S-->>U: Studio 页面或 JSON +``` + +### 3.4 已完成的 VeADK 修改 + +| 模块 | 修改点 | 作用 | +| --- | --- | --- | +| Studio IAM | 支持 IAM Host/Scheme override | IAM 请求可发送到混合云 TOP,而不是固定访问公有云 IAM | +| Studio IAM | `CreateRole` 增加 `DisplayName` | 兼容混合云 IAM 的必填参数 | +| Studio IAM | 缺失的公有云系统 Policy 可跳过 | 混合云未内置部分系统 Policy 时,继续使用已校验的 Studio 自定义 Policy;其他 IAM 错误仍失败 | +| VeFaaS 镜像部署 | `CreateFunction` 透传 `IAM_ROLE` | 让镜像 Function 获得 STS credential 挂载 | +| VeFaaS 镜像部署 | 透传 `project_name` | Function 创建到指定项目,而不是隐式落入默认项目 | +| Studio 部署配置 | 映射 `VOLCENGINE_AGENTKIT_HOST/SCHEME/REGION/SERVICE` | AgentKit SDK 在 Function 内访问混合云 OpenAPI | +| Studio OpenAPI 代理 | endpoint 支持 HTTP/HTTPS Scheme | 兼容 POC HTTP TOP;生产环境仍应使用可信 HTTPS | + +### 3.5 优缺点 + +优点: + +- 已完成端到端验证,交付风险最低; +- 不依赖公有云 VeFaaS Application; +- Function 原生支持 IAM Role 和 STS 自动刷新; +- APIG 可以复用现有 Gateway,并通过独立 Host 隔离路由; +- 不需要修改 AgentKit Runtime 后端。 + +不足: + +- VeFaaS 没有面向客户的产品入口,Function、Revision、实例和日志管理不直观; +- Studio 生命周期分散在镜像仓库、VeFaaS 和 APIG 三个资源域; +- 当前 POC 使用 HTTP,且尚未完成生产级 SSO、TLS 和访问策略验收; +- Studio Role 权限较宽,生产环境应拆分只读、开发者和管理员权限。 + +## 4. 备选方案:通过 AgentKit Runtime 部署 Studio + +### 4.1 目标 + +将 Studio 作为 AgentKit 平台的一类托管工作负载。用户在 AgentKit 控制台创建、更新、发布、查看日志和访问 Studio,不直接感知底层 VeFaaS Function。 + +```mermaid +flowchart LR + Builder["Mac 或 CI\n构建 linux/amd64 镜像"] --> CR["混合云镜像仓库"] + CR --> Runtime["AgentKit Runtime\nRuntimeType=Studio"] + Runtime --> Role["Runtime Role / STS"] + Runtime --> OpenAPI["AgentKit OpenAPI"] + Browser["用户浏览器"] --> WebEndpoint["Runtime Web Endpoint\nTLS + SSO"] + WebEndpoint --> Runtime +``` + +### 4.2 当前 Runtime 接口可以复用的能力 + +现有 `CreateRuntime` 已具备以下基础字段: + +| 当前字段 | Studio 用途 | +| --- | --- | +| `Name` | Studio 实例名称 | +| `ArtifactType` / `ArtifactUrl` | 指定已经构建并推送的镜像制品 | +| `RoleName` | 为 Studio Runtime 注入临时 STS credential | +| `CpuMilli` / `MemoryMb` | Studio 资源规格 | +| `MinInstance` / `MaxInstance` | 实例数和弹性范围 | +| `Envs` | 注入非敏感 endpoint、region 和功能配置 | +| `AuthorizerConfiguration` | 现有 API Endpoint 鉴权配置 | +| `NetworkConfiguration` | 公网或 VPC 网络配置 | +| `Tags` | 标记 Studio 归属、版本和创建来源 | + +镜像仍由本地或 CI 构建,Runtime 只负责拉取镜像和发布,因此不依赖混合云云上构建能力。 + +### 4.3 仅复用当前 Runtime 的临时方案 + +可以将 Studio 镜像按普通 Runtime 创建,并使用 Tag 标记 `workload=studio`。该方案能够复用 Runtime 的版本、发布、实例和日志管理,但仍需在 Runtime Endpoint 前增加 APIG/BFF: + +```mermaid +flowchart LR + Browser --> APIG["APIG/BFF\n用户鉴权"] + APIG -->|安全注入 Runtime 凭证| Runtime["普通 AgentKit Runtime\nTag: workload=studio"] +``` + +该方案不建议作为最终形态,原因如下: + +- 当前 Runtime 使用 API Key Header,浏览器不能直接安全携带; +- APIG 需要保存或动态获得 Runtime API Key,增加 Secret 生命周期管理; +- Runtime 默认协议围绕 `/invoke` 等 Agent API 设计,无法表达 Web 首页、静态资源、Cookie、重定向和 SSO Callback; +- 普通 Studio Runtime 会混入智能体 Runtime 列表,语义和权限边界不清晰。 + +### 4.4 推荐的平台化方案:新增 Studio Runtime 类型 + +建议 Runtime 控制面新增 `RuntimeType`: + +```text +RuntimeType = Agent | Studio +``` + +建议的 `CreateRuntime` 扩展字段如下。字段名称为方案建议,最终以 OpenAPI 评审结果为准。 + +```json +{ + "Name": "veadk-studio", + "RuntimeType": "Studio", + "ArtifactType": "Image", + "ArtifactUrl": "//veadk-studio:", + "RoleName": "VeADKFrontendServiceRole", + "CpuMilli": 2000, + "MemoryMb": 4096, + "MinInstance": 1, + "MaxInstance": 2, + "Port": 8000, + "HealthCheck": { + "Path": "/ping" + }, + "EndpointConfiguration": { + "Protocol": "HTTP", + "Exposure": "Public", + "AccessMode": "Web", + "AuthMode": "OIDC" + }, + "Envs": [ + {"Key": "VOLCENGINE_AGENTKIT_HOST", "Value": ""}, + {"Key": "VOLCENGINE_AGENTKIT_SCHEME", "Value": "https"}, + {"Key": "VOLCENGINE_AGENTKIT_REGION", "Value": ""}, + {"Key": "VOLCENGINE_AGENTKIT_SERVICE", "Value": "agentkit"} + ] +} +``` + +### 4.5 Runtime 平台需要修改的模块 + +| 模块 | 必需修改 | 说明 | +| --- | --- | --- | +| AgentKit OpenAPI | Create/Get/List/Update 增加 `RuntimeType` | 默认值保持 `Agent`,兼容已有调用方 | +| Runtime Controller | 按类型生成启动、探针和 Endpoint 配置 | Studio 使用端口 8000、`/ping`,不强制 `/invoke` 协议 | +| Runtime Gateway | 增加 Web Endpoint 模式 | 支持 HTML、静态资源、SSE/WebSocket、Cookie、重定向和大响应体 | +| Runtime 鉴权 | 增加 OIDC/SSO Cookie 或 Gateway 会话鉴权 | 解决浏览器无法携带 API Key Header 的问题 | +| IAM/STS | 复用 `RoleName` 注入和刷新 | Studio 从 credential 文件读取临时凭证,禁止注入长期 AK/SK | +| Python SDK | 更新 Runtime request/response 类型 | 暴露 `RuntimeType`、Web Endpoint 和健康检查配置 | +| AgentKit CLI | `launch_types.hybrid` 支持 `runtime_type: studio` | 允许从本地镜像或已经推送的镜像创建 Studio | +| AgentKit 控制台 | 增加 Studio 筛选、详情和“访问 Studio”入口 | Studio 与智能体 Runtime 分开展示,但复用版本、实例、日志和发布页面 | +| VeADK Studio CLI | 增加 `--deploy-target agentkit-runtime` | 生成镜像 Runtime 请求,轮询发布状态并输出 Web Endpoint | +| RBAC | 增加 Studio 查看、访问、更新、管理权限 | 平台权限与 Studio 内部管理员/开发者权限需要分别校验 | + +### 4.6 Runtime 方案的关键验收标准 + +- `CreateRuntime(RuntimeType=Studio)`、更新和 Release 均成功; +- Runtime 状态为 `Ready`,实例为 `RUNNING/Healthy`; +- `/ping` 返回 HTTP 200,静态资源和前端路由正常; +- 浏览器无需扩展或手工 API Key 即可完成 SSO 登录; +- Studio Pod 获得自动刷新的 Role STS credential; +- Studio 能读取并管理同 Region 的 AgentKit Runtime; +- 未登录、无权限用户无法访问 Studio API; +- Runtime 列表可按 `Agent` 和 `Studio` 类型筛选; +- 发布、回滚、日志、监控和 Endpoint 均能从 AgentKit 控制台管理。 + +### 4.7 Studio 调用智能体 Runtime + +Studio 自身无论部署在 Function 还是 Studio Runtime,调用普通智能体 Runtime 的链路保持一致: + +```mermaid +sequenceDiagram + participant B as Browser + participant S as Studio BFF + participant C as AgentKit Control Plane + participant R as Agent Runtime + + B->>S: 选择 Runtime 并发送消息 + S->>C: List/GetRuntime(IAM STS 签名) + C-->>S: Endpoint、网络类型和鉴权配置 + S->>R: 代理 /invoke 或 /run_sse + R-->>S: JSON 或 SSE 响应 + S-->>B: 流式返回结果 +``` + +调用原则: + +- 浏览器只访问 Studio BFF,不接触云 AK/SK; +- Studio 使用自身 Role STS credential 调用 AgentKit 控制面; +- Runtime Endpoint 和 Runtime API Key 仅在 Studio 服务端短时缓存; +- KeyAuth Runtime 由 Studio BFF 添加 Authorization Header; +- Custom JWT Runtime 只转发已经由 Studio/Gateway 验证的用户令牌; +- 公网和私网 Endpoint 分开处理,Studio 所在网络必须能够访问选中的 Runtime Endpoint; +- Runtime 返回 401 时清理连接缓存并重新获取鉴权信息,不将 Key 返回浏览器。 + +## 5. 方案对比 + +| 对比项 | VeFaaS Function + APIG | 普通 Runtime + APIG 适配 | 原生 Studio Runtime | +| --- | --- | --- | --- | +| 当前可用性 | 已验证 | 可做进一步 POC | 需要平台研发 | +| 平台改造量 | 小 | 中 | 大 | +| 镜像构建 | 本地/CI | 本地/CI | 本地/CI | +| IAM Role/STS | VeFaaS 原生支持 | Runtime 原生支持 | Runtime 原生支持 | +| 浏览器访问 | APIG 独立域名 | 仍需 APIG/BFF 注入或转换鉴权 | Runtime Web Endpoint 原生支持 | +| SSO | 在 APIG/Studio 层补齐 | 在 APIG/BFF 层补齐 | Runtime Gateway 原生支持 | +| 管理入口 | VeFaaS 未透出,管理较弱 | AgentKit Runtime 可见 | AgentKit Studio 类型独立管理 | +| 版本/发布/日志 | 分散在底层资源 | 复用 Runtime | 复用 Runtime,语义最清晰 | +| 对现有 Runtime 影响 | 无 | 容易混入智能体列表 | 通过类型默认值保持兼容 | +| Secret 管理 | STS,无长期 AK/SK | STS,但 APIG 可能额外管理 Runtime Key | STS + SSO,无 Runtime Key 注入 | +| 适用阶段 | 近期交付 | 过渡验证 | 中长期产品化 | + +## 6. 推荐落地路径 + +### 阶段一:交付已验证方案 + +- 使用 VeFaaS Function + 共享 APIG Gateway; +- 将现有 POC 脚本固化为 `veadk studio deploy --target hybrid-vefaas`; +- 使用独立域名和 Host Route; +- 为 Studio Role 收敛最小权限; +- 补齐生产 HTTPS、SSO、审计、日志和监控; +- 将 Function、APIG Service、Upstream、Route 和镜像版本记录为一份部署状态。 + +### 阶段二:Runtime 轻量 POC + +- 使用当前 `CreateRuntime` 创建 Studio 镜像 Runtime; +- 使用 Tag 区分 Studio; +- 验证 Role STS、端口、健康检查、静态页面和 SSE; +- 使用临时 APIG/BFF 验证浏览器访问; +- 不把该形态作为最终用户入口。 + +### 阶段三:Studio Runtime 产品化 + +- 在 OpenAPI 和控制台正式增加 `RuntimeType=Studio`; +- 增加 Web Endpoint 与 OIDC/SSO; +- 在 AgentKit 控制台提供 Studio 专属列表和访问入口; +- VeADK CLI 默认通过 AgentKit Runtime 部署 Studio,底层 VeFaaS 仅作为平台内部实现。 + +## 7. 安全与生产要求 + +- AK/SK 仅用于部署控制面调用,不进入镜像、Function/Runtime 环境或浏览器; +- 运行时统一使用 IAM Role STS credential,并验证凭证自动刷新; +- 生产入口必须使用可信 HTTPS,POC HTTP 不作为正式配置; +- Studio Role 应按只读、开发者和管理员能力拆分,避免长期保留 `agentkit:*`; +- APIG Host Route 必须使用独立域名,避免与现有 AgentKit 控制台路由冲突; +- SSO 登录、Studio RBAC 和云资源 IAM 是三层独立授权,不能互相替代; +- 日志和错误响应不得打印 credential 文件内容、Authorization、API Key 或环境变量值; +- Studio 的会话、自动化任务和用户配置需要使用外部持久化存储,不能依赖单 Pod 本地文件。 + +## 8. 结论 + +VeFaaS Function + APIG 已证明 Studio 在混合云部署和访问可行,适合作为近期交付方案。AgentKit Runtime 更适合长期管理,但新增 Runtime 类型只是第一步;只有同时提供 Web Endpoint、浏览器 SSO、健康检查、类型隔离和控制台入口,才能形成完整的 Studio Runtime 产品能力。 + +推荐决策:**近期使用 Function + APIG 交付,平台侧并行设计原生 Studio Runtime,完成后由 VeADK CLI 平滑切换部署目标。** + +## 9. 当前代码修改位置 + +| 文件 | 修改内容 | +| --- | --- | +| `veadk/cli/frontend_deploy_iam.py` | 混合云 IAM endpoint、Role `DisplayName`、系统 Policy 缺失兼容 | +| `veadk/integrations/ve_faas/ve_faas.py` | 镜像 Function 透传 IAM Role 和 Project | +| `veadk/cli/cli_frontend.py` | AgentKit endpoint 环境映射、HTTP/HTTPS endpoint、Runtime 服务端代理 | +| `tests/cli/test_frontend_deploy_iam.py` | IAM endpoint、Role 和 Policy 兼容测试 | +| `tests/integrations/test_ve_faas_image.py` | 镜像 Function Role/Project 透传测试 | +| `tests/cli/test_studio_deploy_target.py` | Studio 混合云 AgentKit endpoint 注入测试 | diff --git a/tests/auth/test_oauth2_auth.py b/tests/auth/test_oauth2_auth.py index 7beae213a..644c0a861 100644 --- a/tests/auth/test_oauth2_auth.py +++ b/tests/auth/test_oauth2_auth.py @@ -545,3 +545,45 @@ def test_from_veidentity_uses_refresh_token_absolute_lifetime( ) assert config.session_timeout_seconds == 30 * 24 * 60 * 60 + + +def test_from_veidentity_supports_vestack_oidc_base_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + identity_client = Mock() + identity_client.get_user_pool.return_value = ( + "pool-id", + "auth.example.com", + ) + identity_client.get_user_pool_client.return_value = ( + "client-id", + "client-secret", + ) + identity_client.get_user_pool_client_refresh_token_lifetime.return_value = None + observed: list[str] = [] + monkeypatch.setenv( + "VEIDENTITY_OIDC_BASE_URL", + "http://{user_pool_domain}/userpool/{user_pool_uid}", + ) + monkeypatch.setattr( + "veadk.auth.middleware.oauth2_auth._fetch_oidc_discovery", + lambda base_url: ( + observed.append(base_url) + or OIDCDiscoveryConfig( + issuer=f"{base_url}/issuer", + authorization_endpoint=f"{base_url}/authorize", + token_endpoint=f"{base_url}/oauth/token", + ) + ), + ) + + OAuth2Config.from_veidentity( + user_pool_uid="pool-id", + client_uid="client-id", + redirect_uri="http://studio.example.com/oauth2/callback", + auto_create=False, + auto_register_callback=False, + identity_client=identity_client, + ) + + assert observed == ["http://auth.example.com/userpool/pool-id"] diff --git a/tests/cli/test_frontend_agent_proxy.py b/tests/cli/test_frontend_agent_proxy.py index c6f31365d..32a49251c 100644 --- a/tests/cli/test_frontend_agent_proxy.py +++ b/tests/cli/test_frontend_agent_proxy.py @@ -19,16 +19,31 @@ import httpx import pytest from fastapi import FastAPI +from starlette.websockets import WebSocketDisconnect from fastapi.testclient import TestClient from typing_extensions import Self from veadk.cli.frontend_agent_proxy import ( _rewrite_body, + _websocket_origin, mount_agent_surface_proxy_routes, ) from veadk.cli.frontend_sandbox_proxy import SandboxProxyTarget +def test_hermes_port_proxy_websocket_uses_local_service_origin() -> None: + target = "https://sandbox.example/proxy/4500/api/ws?Authorization=secret" + + assert ( + _websocket_origin("hermes", "proxy/4500/api/ws", target) + == "http://localhost:4500" + ) + assert ( + _websocket_origin("openclaw", "openclaw/api/ws", target) + == "https://sandbox.example" + ) + + def test_agent_surface_rewrite_only_changes_the_agent_base_path() -> None: prefix = "/web/hermes/sessions/session-1/surface/token-1" body = ( @@ -65,6 +80,89 @@ def test_hermes_surface_rewrite_removes_private_gateway_query() -> None: assert "hermes-secret" not in rewritten +def test_hermes_aio_rewrite_routes_workspace_panels_through_surface() -> None: + prefix = "/web/hermes/sessions/session-1/surface/token-1" + body = ( + b'" + b'' + ) + + rewritten = _rewrite_body(body, "text/html", prefix, "hermes").decode() + + for path in ( + "/code-server/", + "/terminal", + "/jupyter/lab", + "/v1/ping", + "/proxy/8642/v1/chat/completions", + "/static/sandbox/app.css", + ): + assert f"{prefix}{path}" in rewritten + + +def test_hermes_aio_entrypoint_keeps_its_relative_panel_resolution() -> None: + prefix = "/web/hermes/sessions/session-1/surface/token-1" + body = ( + b"AIO Sandbox" + b'' + ) + + rewritten = _rewrite_body(body, "text/html", prefix, "hermes").decode() + + assert 'const code = "/code-server/"' in rewritten + assert 'const terminal = "/terminal"' in rewritten + assert f"{prefix}/code-server/" not in rewritten + + +@pytest.mark.parametrize("proxy_kind", ["proxy", "absproxy"]) +@pytest.mark.parametrize("port", ["4500", "9119"]) +def test_hermes_dashboard_rewrite_preserves_its_port_proxy_mount( + proxy_kind: str, + port: str, +) -> None: + prefix = "/web/hermes/sessions/session-1/surface/token-1" + body = ( + b"Hermes Agent - Dashboard" + b'' + b'' + b'' + ) + + rewritten = _rewrite_body( + body, + "text/html", + prefix, + "hermes", + upstream_path=f"{proxy_kind}/{port}/", + ).decode() + + for path in ( + "/favicon.ico", + "/assets/app.js", + "/api/status", + "/api/ws", + "/files", + "/chat", + "/logs", + "/cron", + "/channels", + "/mcp", + ): + assert f"{prefix}/{proxy_kind}/{port}{path}" in rewritten + + def test_deepseek_harness_rewrite_routes_root_assets_through_surface() -> None: prefix = "/web/deepseek-harness/sessions/session-1/surface/token-1" body = ( @@ -180,6 +278,68 @@ def _target(kind: str, session_id: str, token: str) -> SandboxProxyTarget: assert "server-secret" not in response.text +def test_agent_surface_proxy_accepts_async_target_resolver( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _Client: + def __init__(self, **_: object) -> None: + pass + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def request(self, _method: str, _url: str, **_: object) -> httpx.Response: + return httpx.Response( + 200, content=b"ok", headers={"content-type": "text/plain"} + ) + + monkeypatch.setattr("veadk.cli.frontend_agent_proxy.httpx.AsyncClient", _Client) + app = FastAPI() + + async def _target( + kind: str, + session_id: str, + token: str, + ) -> SandboxProxyTarget: + assert (kind, session_id, token) == ("hermes", "session-1", "token-1") + return SandboxProxyTarget(endpoint="https://sandbox.example/") + + mount_agent_surface_proxy_routes(app, _target) + + with TestClient(app) as client: + response = client.get( + "/web/hermes/sessions/session-1/surface/token-1/proxy/4500/" + ) + + assert response.status_code == 200 + assert response.text == "ok" + + +def test_agent_surface_websocket_upgrades_before_invalid_capability_close() -> None: + app = FastAPI() + + def _missing_target( + _kind: str, + _session_id: str, + _token: str, + ) -> SandboxProxyTarget: + raise KeyError("expired session") + + mount_agent_surface_proxy_routes(app, _missing_target) + + with TestClient(app) as client: + with pytest.raises(WebSocketDisconnect) as error: + with client.websocket_connect( + "/web/hermes/sessions/expired/surface/expired/proxy/4500/api/pty" + ) as websocket: + websocket.receive_text() + + assert error.value.code == 1008 + + def test_deepseek_harness_proxy_keeps_endpoint_auth_server_side( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/cli/test_frontend_deploy_iam.py b/tests/cli/test_frontend_deploy_iam.py index 5e3b9cc7b..07102f717 100644 --- a/tests/cli/test_frontend_deploy_iam.py +++ b/tests/cli/test_frontend_deploy_iam.py @@ -204,6 +204,27 @@ def test_existing_frontend_role_policy_error_fails_fast( service.create_role.assert_not_called() +def test_public_cloud_missing_system_policy_fails_fast( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = MagicMock() + service.get_role.return_value = { + "Result": {"Role": {"Trn": "trn:iam::123:role/VeADKFrontendServiceRole"}} + } + service.update_policy.return_value = {"Result": {}} + service.get_policy.return_value = _policy_response() + service.list_attached_role_policies.return_value = { + "Result": {"AttachedPolicyMetadata": [{"PolicyName": "VeADKFrontendPolicy"}]} + } + service.attach_role_policy.side_effect = RuntimeError( + "PolicyNotExist: Policy does not exist" + ) + _install_iam_service(monkeypatch, service) + + with pytest.raises(RuntimeError, match="PolicyNotExist"): + ensure_frontend_role("ak", "sk") + + def test_existing_frontend_policy_uses_new_document_and_verifies_it( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/cli/test_frontend_deploy_iam_vestack.py b/tests/cli/test_frontend_deploy_iam_vestack.py new file mode 100644 index 000000000..eaab9227f --- /dev/null +++ b/tests/cli/test_frontend_deploy_iam_vestack.py @@ -0,0 +1,69 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +import importlib +import json +from unittest.mock import MagicMock + +import pytest + +from veadk.cli.frontend_deploy_iam_vestack import ensure_frontend_role_vestack +from veadk.cli.frontend_deploy_policy import ( + FRONTEND_DEPLOY_POLICY, + FRONTEND_DEPLOY_SYSTEM_POLICIES, +) + + +def _install_iam_service(monkeypatch: pytest.MonkeyPatch, service: MagicMock) -> None: + iam_module = importlib.import_module("volcengine.iam.IamService") + monkeypatch.setattr(iam_module, "IamService", lambda: service) + + +def _existing_role_service() -> MagicMock: + service = MagicMock() + service.get_role.return_value = { + "Result": {"Role": {"Trn": "trn:iam::123:role/VeADKFrontendServiceRole"}} + } + service.update_policy.return_value = {"Result": {}} + service.get_policy.return_value = { + "Result": {"Policy": {"PolicyDocument": json.dumps(FRONTEND_DEPLOY_POLICY)}} + } + service.list_attached_role_policies.return_value = { + "Result": {"AttachedPolicyMetadata": [{"PolicyName": "VeADKFrontendPolicy"}]} + } + return service + + +def test_vestack_skips_only_unavailable_system_policies( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = _existing_role_service() + + def attach_policy(request: dict) -> dict: + if request["PolicyName"] == FRONTEND_DEPLOY_SYSTEM_POLICIES[0]: + raise RuntimeError("PolicyNotExist: Policy does not exist") + return {"Result": {}} + + service.attach_role_policy.side_effect = attach_policy + _install_iam_service(monkeypatch, service) + + trn = ensure_frontend_role_vestack("ak", "sk") + + assert trn == "trn:iam::123:role/VeADKFrontendServiceRole" + attempted_system_policies = [ + call.args[0]["PolicyName"] for call in service.attach_role_policy.call_args_list + ] + assert attempted_system_policies == list(FRONTEND_DEPLOY_SYSTEM_POLICIES) + + +def test_vestack_does_not_hide_other_policy_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = _existing_role_service() + service.attach_role_policy.side_effect = RuntimeError("permission denied") + _install_iam_service(monkeypatch, service) + + with pytest.raises(RuntimeError, match="permission denied"): + ensure_frontend_role_vestack("ak", "sk") diff --git a/tests/cli/test_frontend_runtime_proxy.py b/tests/cli/test_frontend_runtime_proxy.py index 130f50999..163937b15 100644 --- a/tests/cli/test_frontend_runtime_proxy.py +++ b/tests/cli/test_frontend_runtime_proxy.py @@ -1299,6 +1299,25 @@ def test_environment_sandbox_client_uses_studio_provider_endpoint_and_region( assert client.host == expected_host +def test_environment_sandbox_client_preserves_vestack_agentkit_host( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("VOLCENGINE_ACCESS_KEY", "volc-ak") + monkeypatch.setenv("VOLCENGINE_SECRET_KEY", "volc-sk") + monkeypatch.setenv("VEADK_STUDIO_DEPLOY_TARGET", "vestack") + monkeypatch.setenv("VOLCENGINE_AGENTKIT_HOST", "agentkit.internal.example") + app = _create_frontend_app(monkeypatch, tmp_path, provider="volcengine") + + client = app.state.environment_sandbox_resolver._client_factory( + "volcengine", + "e70", + ) + + assert client.region == "e70" + assert client.host == "agentkit.internal.example" + + def test_environment_sandbox_client_rejects_cross_provider_mount( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tests/cli/test_frontend_sandbox.py b/tests/cli/test_frontend_sandbox.py index 09921cb65..b69feae50 100644 --- a/tests/cli/test_frontend_sandbox.py +++ b/tests/cli/test_frontend_sandbox.py @@ -67,6 +67,11 @@ mount_sandbox_agent_routes, mount_sandbox_routes, ) +from veadk.cli.frontend_sandbox_managed_tool_vestack import ( + VeStackAgentkitSandboxGateway, + VeStackManagedTool, + VeStackManagedToolSpec, +) class _FakeCodex: @@ -317,6 +322,8 @@ def __init__(self) -> None: self.envs: list[dict[str, str] | None] = [] self.deleted: list[SandboxCloudSession] = [] self.deleted_snapshots: list[SandboxCloudSnapshot] = [] + self.deleted_managed_tools: list[VeStackManagedTool] = [] + self.created_managed_tool_specs: list[VeStackManagedToolSpec] = [] self.thread_ids: list[str] = [] self.connections: list[_FakeCodex] = [] self.sessions: dict[str, SandboxCloudSession] = { @@ -334,6 +341,45 @@ def __init__(self) -> None: ) } self.snapshots: dict[str, SandboxCloudSnapshot] = {} + self.managed_tools: dict[str, VeStackManagedTool] = {} + + async def list_managed_tools( + self, agent_kind: str, owner_id: str | None = None + ) -> list[VeStackManagedTool]: + return [ + tool + for tool in self.managed_tools.values() + if tool.agent_kind == agent_kind + and (owner_id is None or tool.created_by == owner_id) + ] + + async def create_managed_tool( + self, + spec: VeStackManagedToolSpec, + *, + display_name: str, + owner_id: str, + creator_name: str, + agent_kind: str, + ) -> VeStackManagedTool: + self.created_managed_tool_specs.append(spec) + tool = VeStackManagedTool( + tool_id=f"managed-tool-{len(self.managed_tools) + 1}", + name=f"VeADK-{agent_kind}", + region="e70", + status="Ready", + created_at="2026-09-01T08:00:00Z", + display_name=display_name, + created_by=owner_id, + creator_name=creator_name, + agent_kind=agent_kind, + ) + self.managed_tools[tool.tool_id] = tool + return tool + + async def delete_managed_tool(self, tool: VeStackManagedTool) -> None: + self.deleted_managed_tools.append(tool) + self.managed_tools.pop(tool.tool_id, None) async def get_tool(self, tool_id: str) -> SimpleNamespace: self.tool_ids.append(tool_id) @@ -437,16 +483,49 @@ async def drain(self) -> None: return None +def test_managed_tool_api_is_only_on_vestack_gateway() -> None: + assert not hasattr(AgentkitSandboxGateway, "create_managed_tool") + assert not hasattr(AgentkitSandboxGateway, "list_managed_tools") + assert not hasattr(AgentkitSandboxGateway, "delete_managed_tool") + assert hasattr(VeStackAgentkitSandboxGateway, "create_managed_tool") + assert hasattr(VeStackAgentkitSandboxGateway, "list_managed_tools") + assert hasattr(VeStackAgentkitSandboxGateway, "delete_managed_tool") + + +def test_agent_surface_capability_rejects_missing_malformed_and_wrong_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("VEADK_STUDIO_KNOWLEDGE_SIGNING_KEY", raising=False) + assert not frontend_sandbox._valid_agent_surface_capability( + "token", "hermes", "session-1" + ) + + monkeypatch.setenv("VEADK_STUDIO_KNOWLEDGE_SIGNING_KEY", "test-signing-key") + assert not frontend_sandbox._valid_agent_surface_capability( + "malformed", "hermes", "session-1" + ) + token = frontend_sandbox._agent_surface_capability("hermes", "session-1") + assert frontend_sandbox._valid_agent_surface_capability( + token, "hermes", "session-1" + ) + _version, remainder = token.split(".", 1) + assert not frontend_sandbox._valid_agent_surface_capability( + f"wrong.{remainder}", "hermes", "session-1" + ) + + def _app( gateway: _FakeGateway, tool_id: str | None = "tool-studio", snapshot_tool_id: str | None = "tool-studio-snapshot", + managed_tool_spec: VeStackManagedToolSpec | None = None, ) -> FastAPI: app = FastAPI() service = SandboxConversationService( gateway, tool_id=tool_id, snapshot_tool_id=snapshot_tool_id, + managed_tool_spec=managed_tool_spec, ) def _owner(request: Request) -> str: @@ -476,6 +555,7 @@ def _agent_app( *, snapshot_tool_ids: dict[str, str] | None = None, agentkit_cli_tool_id: str | None = "tool-dev", + hermes_managed_tool_spec: VeStackManagedToolSpec | None = None, ) -> FastAPI: if snapshot_tool_ids is None: snapshot_tool_ids = { @@ -529,8 +609,13 @@ def _creator(request: Request) -> str: "hermes": SandboxAgentSessionService( gateway, kind="hermes", - tool_id="tool-hermes", - snapshot_tool_id=snapshot_tool_ids.get("hermes"), + tool_id=None if hermes_managed_tool_spec else "tool-hermes", + snapshot_tool_id=( + None + if hermes_managed_tool_spec + else snapshot_tool_ids.get("hermes") + ), + managed_tool_spec=hermes_managed_tool_spec, ), }, _owner, @@ -540,6 +625,375 @@ def _creator(request: Request) -> str: return app +def test_hermes_managed_tool_mode_creates_one_tool_per_agent() -> None: + gateway = _FakeGateway() + spec = VeStackManagedToolSpec( + tool_type="Station-Hermes", + model_agent_name="ep-deepseek-test", + model_agent_api_base="http://modelcenter.example:6789", + model_agent_api_key="test-model-key", + model_agent_model_id="ep-deepseek-test", + role_name="VeADKFrontendServiceRole", + ) + alice_headers = { + "X-Test-User": "tenant-alice", + "X-Test-Creator": "alice@example.com", + } + with TestClient(_agent_app(gateway, hermes_managed_tool_spec=spec)) as client: + capabilities = client.get("/web/hermes/capabilities", headers=alice_headers) + created = client.post( + "/web/hermes/sessions", + headers=alice_headers, + json={"displayName": "Alice Hermes", "persistent": False, "diskGb": 32}, + ) + session_id = created.json()["sessionId"] + alice_list = client.get("/web/hermes/sessions", headers=alice_headers) + other_list = client.get( + "/web/hermes/sessions", headers={"X-Test-User": "tenant-bob"} + ) + admin_list = client.get( + "/web/hermes/sessions", + headers={"X-Test-User": "admin", "X-Test-Role": "admin"}, + ) + deleted = client.delete( + f"/web/hermes/sessions/{session_id}", headers=alice_headers + ) + + assert capabilities.status_code == 200 + assert capabilities.json() == { + "enabled": True, + "reason": "", + "persistentEnabled": True, + "persistentReason": "", + "persistentRequired": True, + "storageMode": "disk", + "diskGbDefault": 10, + "diskGbMin": 5, + "diskGbMax": 100, + } + assert created.status_code == 200 + assert created.json()["displayName"] == "Alice Hermes" + assert created.json()["createdBy"] == "alice@example.com" + assert created.json()["persistent"] is True + assert len(gateway.created_managed_tool_specs) == 1 + assert gateway.created_managed_tool_specs[0].disk_gb == 32 + assert gateway.tool_ids[-2:] == ["managed-tool-1", "managed-tool-1"] + assert [item["sessionId"] for item in alice_list.json()["sessions"]] == [session_id] + assert other_list.json() == {"sessions": []} + assert [item["sessionId"] for item in admin_list.json()["sessions"]] == [session_id] + assert deleted.json() == {"deleted": True} + assert gateway.managed_tools == {} + assert [tool.tool_id for tool in gateway.deleted_managed_tools] == [ + "managed-tool-1" + ] + + +def test_codex_managed_tool_mode_creates_codeenv_tool_per_agent() -> None: + gateway = _FakeGateway() + spec = VeStackManagedToolSpec( + tool_type="CodeEnv", + role_name="VeADKFrontendServiceRole", + ) + alice_headers = { + "X-Test-User": "tenant-alice", + "X-Test-Creator": "alice@example.com", + } + with TestClient(_app(gateway, managed_tool_spec=spec)) as client: + capabilities = client.get("/web/sandbox/capabilities", headers=alice_headers) + created = client.post( + "/web/sandbox/sessions", + headers=alice_headers, + json={"displayName": "Alice Codex", "persistent": False, "diskGb": 20}, + ) + session_id = created.json()["sessionId"] + alice_list = client.get("/web/sandbox/sessions", headers=alice_headers) + other_list = client.get( + "/web/sandbox/sessions", headers={"X-Test-User": "tenant-bob"} + ) + deleted = client.delete( + f"/web/sandbox/sessions/{session_id}", headers=alice_headers + ) + + assert capabilities.status_code == 200 + assert capabilities.json() == { + "enabled": True, + "reason": "", + "persistentEnabled": True, + "persistentReason": "", + "persistentRequired": True, + "storageMode": "disk", + "diskGbDefault": 10, + "diskGbMin": 5, + "diskGbMax": 100, + "endpointExportEnabled": True, + } + assert created.status_code == 200 + assert created.json()["displayName"] == "Alice Codex" + assert created.json()["createdBy"] == "alice@example.com" + assert created.json()["persistent"] is True + assert len(gateway.created_managed_tool_specs) == 1 + assert gateway.created_managed_tool_specs[0].disk_gb == 20 + assert [item["sessionId"] for item in alice_list.json()["sessions"]] == [session_id] + assert other_list.json() == {"sessions": []} + assert deleted.json() == {"deleted": True} + assert gateway.managed_tools == {} + + +@pytest.mark.parametrize("disk_gb", [4, 101, 10.5, True, "10"]) +def test_managed_tool_mode_rejects_invalid_disk_size(disk_gb: object) -> None: + gateway = _FakeGateway() + spec = VeStackManagedToolSpec( + tool_type="Station-Hermes", + role_name="VeADKFrontendServiceRole", + ) + + with TestClient(_agent_app(gateway, hermes_managed_tool_spec=spec)) as client: + response = client.post( + "/web/hermes/sessions", + headers={"X-Test-User": "alice"}, + json={"displayName": "Hermes", "diskGb": disk_gb}, + ) + + assert response.status_code == 422 + assert gateway.created_managed_tool_specs == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("spec", "expected_model_agent_name", "expected_port"), + [ + ( + VeStackManagedToolSpec( + tool_type="CodeEnv", + role_name="VeADKFrontendServiceRole", + port=8642, + disk_gb=24, + ), + None, + 8642, + ), + ( + VeStackManagedToolSpec( + tool_type="Station-Hermes", + role_name="VeADKFrontendServiceRole", + model_agent_name="ep-deepseek-test", + model_agent_api_base="http://modelcenter.example:6789", + model_agent_api_key="test-model-key", + model_agent_model_id="ep-deepseek-test", + port=4500, + disk_gb=32, + ), + "ep-deepseek-test", + 4500, + ), + ], +) +async def test_gateway_sends_console_equivalent_model_environment_for_hermes( + monkeypatch: pytest.MonkeyPatch, + spec: VeStackManagedToolSpec, + expected_model_agent_name: str | None, + expected_port: int, +) -> None: + requests: list[dict[str, object]] = [] + gateway = VeStackAgentkitSandboxGateway(object(), region_candidates=("e70",)) + + async def _call(method_name: str, request: object, *, region: str = "") -> object: + assert region == "e70" + if method_name == "create_tool": + requests.append(request.model_dump(by_alias=True, exclude_none=True)) + return SimpleNamespace(tool_id="managed-tool-1") + assert method_name == "get_tool" + return SimpleNamespace( + tool_id="managed-tool-1", + name="VeADK-Test", + status="Ready", + tags=[], + ) + + monkeypatch.setattr(gateway, "_call", _call) + + await gateway.create_managed_tool( + spec, + display_name="测试智能体", + owner_id="tenant-alice", + creator_name="alice@example.com", + agent_kind=("hermes" if spec.tool_type == "Station-Hermes" else "codex"), + ) + + assert requests[0]["ToolType"] == spec.tool_type + assert requests[0]["Port"] == expected_port + assert requests[0].get("ModelAgentName") == expected_model_agent_name + envs = {item["Key"]: item["Value"] for item in requests[0]["Envs"]} + assert envs["DiskGb"] == str(spec.disk_gb) + if spec.tool_type == "Station-Hermes": + assert envs == { + "DiskGb": "32", + "MODEL_AGENT_API_BASE": "http://modelcenter.example:6789", + "MODEL_AGENT_API_KEY": "test-model-key", + "MODEL_AGENT_MODEL_ID": "ep-deepseek-test", + } + + +@pytest.mark.asyncio +async def test_vestack_gateway_lists_owned_managed_tools_across_pages( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = VeStackAgentkitSandboxGateway(object(), region_candidates=("region-a",)) + requests: list[dict[str, object]] = [] + + async def _call(method_name: str, request: object, *, region: str = "") -> object: + assert method_name == "list_tools" + assert region == "region-a" + payload = request.model_dump(by_alias=True, exclude_none=True) + requests.append(payload) + page = len(requests) + return SimpleNamespace( + tools=[ + SimpleNamespace( + tool_id=f"tool-{page}", + name=f"Tool {page}", + status="Ready", + created_at=f"2026-09-0{page}T00:00:00Z", + tags=[ + {"Key": "veadk_display_name", "Value": f"Agent {page}"}, + SimpleNamespace(key="veadk_owner", value="owner-1"), + {"key": "veadk_creator_name", "value": "Alice"}, + {"Key": "veadk_agent_kind", "Value": "hermes"}, + ], + ) + ], + next_token="next" if page == 1 else "", + ) + + monkeypatch.setattr(gateway, "_call", _call) + + tools = await gateway.list_managed_tools("hermes", owner_id="owner-1") + + assert [tool.tool_id for tool in tools] == ["tool-2", "tool-1"] + assert tools[0].display_name == "Agent 2" + assert tools[0].created_by == "owner-1" + assert tools[0].creator_name == "Alice" + assert tools[0].agent_kind == "hermes" + assert "NextToken" not in requests[0] + assert requests[1]["NextToken"] == "next" + assert len(requests[0]["TagFilters"]) == 3 + + +@pytest.mark.asyncio +async def test_vestack_gateway_retries_not_found_region_for_managed_tools( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = VeStackAgentkitSandboxGateway( + object(), region_candidates=("region-a", "region-b") + ) + regions: list[str] = [] + + async def _call(method_name: str, request: object, *, region: str = "") -> object: + del method_name, request + regions.append(region) + if region == "region-a": + raise RuntimeError("InvalidResource.NotFound") + return SimpleNamespace(tools=[], next_token="") + + monkeypatch.setattr(gateway, "_call", _call) + + assert await gateway.list_managed_tools("codex") == [] + assert regions == ["region-a", "region-b"] + + +@pytest.mark.asyncio +async def test_vestack_gateway_reports_managed_tool_failures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = VeStackAgentkitSandboxGateway(object(), region_candidates=("region-a",)) + + async def _list_error(*_args: object, **_kwargs: object) -> object: + raise RuntimeError("AccessDenied") + + monkeypatch.setattr(gateway, "_call", _list_error) + with pytest.raises(SandboxProvisioningError, match="AccessDenied"): + await gateway.list_managed_tools("hermes") + + async def _create_without_id( + method_name: str, request: object, *, region: str = "" + ) -> object: + del request, region + assert method_name == "create_tool" + return SimpleNamespace(tool_id="") + + monkeypatch.setattr(gateway, "_call", _create_without_id) + with pytest.raises(SandboxProvisioningError, match="缺少 ToolId"): + await gateway.create_managed_tool( + VeStackManagedToolSpec(tool_type="CodeEnv", role_name="role"), + display_name="Codex", + owner_id="owner-1", + creator_name="Alice", + agent_kind="codex", + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("terminal_status", ["Failed", "Building"]) +async def test_vestack_gateway_reports_failed_or_timed_out_tool_creation( + monkeypatch: pytest.MonkeyPatch, + terminal_status: str, +) -> None: + gateway = VeStackAgentkitSandboxGateway(object(), region_candidates=("region-a",)) + monkeypatch.setattr( + "veadk.cli.frontend_sandbox_managed_tool_vestack._READY_ATTEMPTS", 2 + ) + + async def _sleep(_seconds: float) -> None: + return None + + monkeypatch.setattr( + "veadk.cli.frontend_sandbox_managed_tool_vestack.asyncio.sleep", _sleep + ) + + async def _call(method_name: str, request: object, *, region: str = "") -> object: + del request, region + if method_name == "create_tool": + return SimpleNamespace(tool_id="tool-1") + return SimpleNamespace( + tool_id="tool-1", + name="Tool", + status=terminal_status, + tags=[], + ) + + monkeypatch.setattr(gateway, "_call", _call) + expected = "当前状态:failed" if terminal_status == "Failed" else "创建超时" + with pytest.raises(SandboxProvisioningError, match=expected): + await gateway.create_managed_tool( + VeStackManagedToolSpec(tool_type="CodeEnv", role_name="role"), + display_name="Codex", + owner_id="owner-1", + creator_name="Alice", + agent_kind="codex", + ) + + +@pytest.mark.asyncio +async def test_vestack_gateway_delete_is_idempotent_and_wraps_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = VeStackAgentkitSandboxGateway(object()) + tool = VeStackManagedTool(tool_id="tool-1", name="Tool", region="region-a") + + async def _not_found(*_args: object, **_kwargs: object) -> object: + raise RuntimeError("InvalidResource.NotFound") + + monkeypatch.setattr(gateway, "_call", _not_found) + await gateway.delete_managed_tool(tool) + + async def _denied(*_args: object, **_kwargs: object) -> object: + raise RuntimeError("AccessDenied") + + monkeypatch.setattr(gateway, "_call", _denied) + with pytest.raises(SandboxProvisioningError, match="AccessDenied"): + await gateway.delete_managed_tool(tool) + + def test_sandbox_route_response_types_resolve_in_openapi() -> None: gateway = _FakeGateway() @@ -667,6 +1121,257 @@ def test_deepseek_harness_reuses_codex_tools_and_has_its_own_surface() -> None: assert "tool-studio-snapshot" in gateway.tool_ids +def test_hermes_surface_targets_the_native_dashboard_port_proxy() -> None: + service = SandboxAgentSessionService( + _FakeGateway(), + kind="hermes", + tool_id="tool-hermes", + surface_path="/proxy/4500/", + ) + + assert service.surface_path == "/proxy/4500/" + + +@pytest.mark.asyncio +async def test_agent_surface_capability_resolves_across_replicas( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VEADK_STUDIO_KNOWLEDGE_SIGNING_KEY", "shared-test-key") + gateway = _FakeGateway() + first = SandboxAgentSessionService( + gateway, + kind="hermes", + tool_id="tool-studio", + ) + second = SandboxAgentSessionService( + gateway, + kind="hermes", + tool_id="tool-studio", + ) + created = await first.create( + "alice", + display_name="Hermes", + creator_name="alice@example.com", + persistent=False, + ) + cloud, token = await first.open(created.instance_id, "alice") + + target = await second.resolve_surface_proxy_target(created.instance_id, token) + + assert target.endpoint == cloud.endpoint + with pytest.raises(PermissionError): + await second.resolve_surface_proxy_target(created.instance_id, f"{token}x") + + +@pytest.mark.asyncio +async def test_agent_surface_capability_accepts_previous_valid_token_after_reopen( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VEADK_STUDIO_KNOWLEDGE_SIGNING_KEY", "shared-test-key") + now = [1_000] + monkeypatch.setattr(frontend_sandbox.time, "time", lambda: now[0]) + gateway = _FakeGateway() + service = SandboxAgentSessionService( + gateway, + kind="hermes", + tool_id="tool-studio", + ) + created = await service.create( + "alice", + display_name="Hermes", + creator_name="alice@example.com", + persistent=False, + ) + cloud, previous_token = await service.open(created.instance_id, "alice") + now[0] += 1 + _, current_token = await service.open(created.instance_id, "alice") + + assert previous_token != current_token + target = await service.resolve_surface_proxy_target( + created.instance_id, + previous_token, + ) + + assert target.endpoint == cloud.endpoint + with pytest.raises(PermissionError): + await service.resolve_surface_proxy_target( + created.instance_id, + f"{previous_token}x", + ) + + +@pytest.mark.asyncio +async def test_managed_agent_recovers_tool_mapping_on_another_replica( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = _FakeGateway() + gateway.managed_tools = { + "stale-tool": VeStackManagedTool( + tool_id="stale-tool", name="Stale", agent_kind="hermes" + ), + "managed-tool": VeStackManagedTool( + tool_id="managed-tool", + name="Hermes", + display_name="Recovered Hermes", + created_by="alice", + creator_name="Alice", + agent_kind="hermes", + ), + } + gateway.sessions["remote-managed"] = replace( + gateway.sessions["remote-existing"], + tool_id="managed-tool", + instance_id="remote-managed", + display_name="", + creator_name="", + agent_kind="", + ) + original_list_sessions = gateway.list_sessions + + async def _list_sessions( + tool_id: str, username: str | None = None + ) -> list[SandboxCloudSession]: + if tool_id == "stale-tool": + raise SandboxProvisioningError("stale") + return await original_list_sessions(tool_id, username) + + monkeypatch.setattr(gateway, "list_sessions", _list_sessions) + service = SandboxAgentSessionService( + gateway, + kind="hermes", + tool_id=None, + managed_tool_spec=VeStackManagedToolSpec( + tool_type="Station-Hermes", role_name="role", port=4500 + ), + ) + + cloud = await service._cloud_session("remote-managed") + + assert cloud.display_name == "Recovered Hermes" + assert cloud.creator_name == "Alice" + assert cloud.agent_kind == "hermes" + assert cloud.persistent is True + + +@pytest.mark.asyncio +async def test_managed_agent_list_skips_retiring_and_racy_tools() -> None: + attempted_tool_ids: list[str] = [] + + class _RacyGateway(_FakeGateway): + async def list_sessions( + self, + tool_id: str, + username: str | None = None, + ) -> list[SandboxCloudSession]: + attempted_tool_ids.append(tool_id) + if tool_id == "managed-tool-racy": + raise SandboxProvisioningError("AgentKit ListSessions InternalError") + return await super().list_sessions(tool_id, username) + + gateway = _RacyGateway() + gateway.managed_tools = { + "managed-tool-ready": VeStackManagedTool( + tool_id="managed-tool-ready", + name="VeADK-Hermes-ready", + status="Ready", + display_name="Ready Hermes", + created_by="alice", + agent_kind="hermes", + ), + "managed-tool-deleting": VeStackManagedTool( + tool_id="managed-tool-deleting", + name="VeADK-Hermes-deleting", + status="Deleting", + display_name="Deleting Hermes", + created_by="alice", + agent_kind="hermes", + ), + "managed-tool-racy": VeStackManagedTool( + tool_id="managed-tool-racy", + name="VeADK-Hermes-racy", + status="Ready", + display_name="Racy Hermes", + created_by="alice", + agent_kind="hermes", + ), + } + gateway.sessions = { + "session-ready": SandboxCloudSession( + tool_id="managed-tool-ready", + instance_id="session-ready", + user_session_id="ready", + endpoint="https://sandbox.example/?Authorization=secret", + status="Ready", + created_by="alice", + ) + } + service = SandboxAgentSessionService( + gateway, + kind="hermes", + managed_tool_spec=VeStackManagedToolSpec( + tool_type="Station-Hermes", + role_name="VeADKFrontendServiceRole", + ), + ) + + sessions = await service.list_sessions("alice") + + assert [session.instance_id for session in sessions] == ["session-ready"] + assert "managed-tool-deleting" not in attempted_tool_ids + assert "managed-tool-racy" in attempted_tool_ids + + +@pytest.mark.asyncio +async def test_agent_terminal_restores_workspace_across_replicas( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VEADK_STUDIO_KNOWLEDGE_SIGNING_KEY", "shared-test-key") + gateway = _FakeGateway() + first = SandboxAgentSessionService( + gateway, + kind="hermes", + tool_id="tool-studio", + ) + second = SandboxAgentSessionService( + gateway, + kind="hermes", + tool_id="tool-studio", + ) + created = await first.create( + "alice", + display_name="Hermes", + creator_name="alice@example.com", + persistent=False, + ) + await first.open(created.instance_id, "alice") + + async def _terminal_url( + endpoint: str, + session_id: str, + *, + direct: bool = False, + ) -> tuple[str, str]: + assert endpoint == created.endpoint + assert session_id == created.instance_id + assert direct is True + return "https://sandbox.example/terminal", "shell-1" + + monkeypatch.setattr( + "veadk.cli.frontend_sandbox.terminal_launch_url", + _terminal_url, + ) + + url, shell_session_id, token = await second.launch_terminal( + created.instance_id, + "alice", + ) + + assert url == "https://sandbox.example/terminal" + assert shell_session_id == "shell-1" + target = await first.resolve_surface_proxy_target(created.instance_id, token) + assert target.endpoint == created.endpoint + + def test_agentkit_cli_uses_dev_tool_and_isolates_admin_by_owner() -> None: gateway = _FakeGateway() alice_headers = { diff --git a/tests/cli/test_frontend_sandbox_options.py b/tests/cli/test_frontend_sandbox_options.py index 69d08f692..4eff4b068 100644 --- a/tests/cli/test_frontend_sandbox_options.py +++ b/tests/cli/test_frontend_sandbox_options.py @@ -131,6 +131,7 @@ def test_local_studio_mounts_snapshot_tools_into_sandbox_services() -> None: " sandbox_gateway,\n" " tool_id=sandbox_chat_codex_tool_id,\n" " snapshot_tool_id=sandbox_chat_codex_snapshot_tool_id,\n" + " managed_tool_spec=codex_managed_tool_spec,\n" " )" ) in source assert ( @@ -166,7 +167,8 @@ def test_local_studio_mounts_snapshot_tools_into_sandbox_services() -> None: ' kind="hermes",\n' " tool_id=sandbox_chat_hermes_tool_id,\n" " snapshot_tool_id=sandbox_chat_hermes_snapshot_tool_id,\n" - " )" + " managed_tool_spec=hermes_managed_tool_spec,\n" + ' surface_path="/proxy/4500/",\n' ) in source diff --git a/tests/cli/test_vestack_studio_deploy.py b/tests/cli/test_vestack_studio_deploy.py new file mode 100644 index 000000000..93f0d7340 --- /dev/null +++ b/tests/cli/test_vestack_studio_deploy.py @@ -0,0 +1,379 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from click.testing import CliRunner + +from veadk.cli import vestack_studio_deploy +from veadk.cli.cli_frontend import studio + + +@pytest.mark.parametrize( + ("omitted_options", "expected_error"), + [ + ({"--public-domain"}, "--public-domain is required"), + ({"--vestack-openapi-url"}, "--vestack-openapi-url must include"), + ({"--image"}, "--image is required"), + ({"--vestack-agentkit-region"}, "--vestack-agentkit-region is required"), + ({"--vestack-apig-cluster-id"}, "--vestack-apig-cluster-id is required"), + ], +) +def test_vestack_cli_requires_custom_endpoint_options( + omitted_options: set[str], + expected_error: str, +) -> None: + options = [ + ("--deploy-target", "vestack"), + ("--provider", "volcengine"), + ("--image", "registry.example.com/studio:v1"), + ("--vefaas-app-name", "veadk-studio"), + ("--public-domain", "studio.example.com"), + ("--vestack-openapi-url", "https://openapi.example.com"), + ("--vestack-agentkit-region", "region-a"), + ("--vestack-apig-cluster-id", "cluster-1"), + ("--volcengine-access-key", "ak"), + ("--volcengine-secret-key", "sk"), + ] + arguments = [ + value + for option, value in options + if option not in omitted_options + for value in (option, value) + ] + + result = CliRunner().invoke(studio, ["deploy", *arguments]) + + assert result.exit_code == 1 + assert expected_error in result.output + + +def test_vestack_deploy_requires_image_and_domain() -> None: + common = { + "access_key": "ak", + "secret_key": "sk", + "session_token": "", + "region": "e70", + "project": "default", + "application_name": "veadk-studio", + "role_trn": "trn:iam::1:role/studio", + "public_domain": "studio.example.com", + } + + with pytest.raises(ValueError, match="image"): + vestack_studio_deploy.deploy_vestack_studio_image(image="", **common) + with pytest.raises(ValueError, match="domain"): + vestack_studio_deploy.deploy_vestack_studio_image( + image="registry.example.com/studio:v1", + **{**common, "public_domain": ""}, + ) + + +def test_vestack_deploy_reuses_named_resources_and_does_not_inject_credentials( + monkeypatch, +) -> None: + monkeypatch.setenv("IAM_ROLE", "original-role") + function = SimpleNamespace(id="fn-1", name="veadk-studio-fn") + function_service = MagicMock() + function_service.client.list_functions.return_value = SimpleNamespace( + items=[function] + ) + function_service.client.get_function.return_value = SimpleNamespace( + envs=[ + SimpleNamespace(key="OAUTH2_CLIENT_SECRET", value="existing-secret"), + SimpleNamespace(key="VOLCENGINE_ACCESS_KEY", value="must-be-removed"), + ] + ) + monkeypatch.setattr(vestack_studio_deploy, "VeFaaS", lambda **_: function_service) + monkeypatch.setattr( + vestack_studio_deploy, "_wait_for_function_release", MagicMock() + ) + + apig = MagicMock() + apig.list_gateways.return_value = SimpleNamespace( + items=[SimpleNamespace(id="gw-1", name="veadk-studio-gateway")] + ) + apig.find_gateway_service.return_value = SimpleNamespace(id="svc-1") + apig.find_upstream.return_value = SimpleNamespace(id="up-1") + apig.find_route.return_value = SimpleNamespace(id="route-1") + apig.get_gateway_external_http_address.return_value = ("192.0.2.10", 32978) + monkeypatch.setattr(vestack_studio_deploy, "APIGateway", lambda *_, **__: apig) + + environment = { + "VOLCENGINE_AGENTKIT_HOST": "ops-top.ops-top.svc:8000", + "VOLCENGINE_SECRET_KEY": "must-not-be-deployed", + } + result = vestack_studio_deploy.deploy_vestack_studio_image( + access_key="deployer-ak", + secret_key="deployer-sk", + session_token="", + region="e70", + project="default", + application_name="veadk-studio", + image="registry.example.com/studio:v1", + role_trn="trn:iam::1:role/studio", + public_domain="studio.example.com", + apig_cluster_id="cluster-1", + apig_namespace="veadk-studio-apig", + environment=environment, + ) + + assert result.endpoint == "http://studio.example.com:32978" + assert result.function_id == "fn-1" + function_service.update_function_envs_and_release.assert_called_once_with( + "fn-1", + {"OAUTH2_REDIRECT_URI": "http://studio.example.com:32978/oauth2/callback"}, + ) + update_request = function_service.client.update_function.call_args.args[0] + deployed_env = {item.key: item.value for item in update_request.envs} + assert deployed_env == { + "OAUTH2_CLIENT_SECRET": "existing-secret", + "VOLCENGINE_AGENTKIT_HOST": "ops-top.ops-top.svc:8000", + } + assert "VOLCENGINE_ACCESS_KEY" not in deployed_env + assert "VOLCENGINE_SECRET_KEY" not in deployed_env + assert vestack_studio_deploy.os.environ["IAM_ROLE"] == "original-role" + apig.create_serverless_gateway.assert_not_called() + apig.create_gateway_service.assert_not_called() + apig.create_vefaas_upstream.assert_not_called() + apig.create_gateway_service_routes.assert_not_called() + + +def test_wait_for_function_release_handles_success_failure_and_timeout( + monkeypatch, +) -> None: + service = SimpleNamespace(client=MagicMock()) + service.client.get_release_status.side_effect = [ + SimpleNamespace(status="Building"), + SimpleNamespace(status="Succeeded"), + ] + monkeypatch.setattr(vestack_studio_deploy.time, "sleep", MagicMock()) + + vestack_studio_deploy._wait_for_function_release( + service, + "fn-1", + attempts=2, + interval_seconds=0, + ) + + service.client.get_release_status.side_effect = None + service.client.get_release_status.return_value = SimpleNamespace(status="Failed") + with pytest.raises(RuntimeError, match="release failed"): + vestack_studio_deploy._wait_for_function_release( + service, + "fn-1", + attempts=1, + interval_seconds=0, + ) + + service.client.get_release_status.return_value = SimpleNamespace(status="Building") + with pytest.raises(TimeoutError, match="did not complete"): + vestack_studio_deploy._wait_for_function_release( + service, + "fn-1", + attempts=1, + interval_seconds=0, + ) + + +@pytest.mark.parametrize( + ("port_args", "expected_codex_port", "expected_hermes_port"), + [ + ((), "8642", "4500"), + ( + ( + "--vestack-codex-port", + "18642", + "--vestack-hermes-port", + "14500", + "--iam-role", + "trn:iam::1:role/existing-studio", + "--vestack-insecure-skip-tls-verify", + "--client-secret", + "test-client-secret", + "--site-title", + "Test Studio", + "--admin", + "admin@example.com", + "--developer", + "developer@example.com", + ), + "18642", + "14500", + ), + ], +) +def test_vestack_cli_configures_independent_hermes_tools( + monkeypatch, + port_args: tuple[str, ...], + expected_codex_port: str, + expected_hermes_port: str, +) -> None: + captured: dict[str, object] = {} + + monkeypatch.setattr( + "veadk.cli.cli_frontend._resolve_or_create_studio_identity_resources", + lambda **_: ("pool-1", "auth.example.com", "client-1"), + ) + + def fake_deploy(**kwargs): + captured.update(kwargs) + return vestack_studio_deploy.VeStackStudioDeployment( + endpoint="http://studio.example.com:32978", + function_id="fn-1", + gateway_id="gw-1", + service_id="svc-1", + upstream_id="up-1", + route_id="route-1", + ) + + monkeypatch.setattr( + "veadk.cli.vestack_studio_deploy.deploy_vestack_studio_image", fake_deploy + ) + monkeypatch.setattr( + "veadk.cli.frontend_deploy_iam_vestack.ensure_frontend_role_vestack", + lambda *_args, **_kwargs: "trn:iam::1:role/studio", + ) + monkeypatch.setattr( + "veadk.integrations.ve_identity.identity_client.IdentityClient.register_callback_for_user_pool_client", + lambda *_args, **_kwargs: None, + ) + + result = CliRunner().invoke( + studio, + [ + "deploy", + "--deploy-target", + "vestack", + "--provider", + "volcengine", + "--image", + "registry.example.com/studio:v1", + "--vefaas-app-name", + "veadk-studio", + "--public-domain", + "studio.example.com", + "--vestack-openapi-url", + "https://openapi.example.com", + "--vestack-agentkit-region", + "e70", + "--vestack-apig-cluster-id", + "cluster-1", + "--user-pool-id", + "pool-1", + "--allowed-client-id", + "client-1", + "--volcengine-access-key", + "ak", + "--volcengine-secret-key", + "sk", + "--vestack-hermes-model-agent-name", + "ep-deepseek-test", + "--vestack-hermes-model-api-base", + "http://modelcenter.example:6789", + "--vestack-hermes-model-api-key", + "test-model-key", + "--vestack-hermes-model-id", + "ep-deepseek-test", + *port_args, + ], + ) + + assert result.exit_code == 0, result.output + environment = captured["environment"] + assert isinstance(environment, dict) + assert environment["VOLCENGINE_REGION"] == "e70" + assert environment["AGENTKIT_SANDBOX_REGION"] == "e70" + assert environment["VEADK_STUDIO_DEPLOY_TARGET"] == "vestack" + assert environment["VEADK_STUDIO_HERMES_TOOL_PER_AGENT"] == "true" + assert environment["VEADK_STUDIO_HERMES_MODEL_AGENT_NAME"] == "ep-deepseek-test" + assert environment["VEADK_STUDIO_HERMES_MODEL_API_BASE"] == ( + "http://modelcenter.example:6789" + ) + assert environment["VEADK_STUDIO_HERMES_MODEL_API_KEY"] == "test-model-key" + assert environment["VEADK_STUDIO_HERMES_MODEL_ID"] == "ep-deepseek-test" + expected_role_name = "existing-studio" if "--iam-role" in port_args else "studio" + assert environment["VEADK_STUDIO_HERMES_ROLE_NAME"] == expected_role_name + assert environment["VEADK_STUDIO_CODEX_TOOL_PER_AGENT"] == "true" + assert environment["VEADK_STUDIO_CODEX_ROLE_NAME"] == expected_role_name + assert environment["VEADK_STUDIO_CODEX_PORT"] == expected_codex_port + assert environment["VEADK_STUDIO_HERMES_PORT"] == expected_hermes_port + assert environment["SANDBOX_CHAT_HERMES"] == "" + assert environment["SANDBOX_CHAT_HERMES_SNAPSHOT"] == "" + + +def test_vestack_cli_rejects_byteplus_provider() -> None: + result = CliRunner().invoke( + studio, + [ + "deploy", + "--deploy-target", + "vestack", + "--provider", + "byteplus", + "--image", + "registry.example.com/studio:v1", + "--vefaas-app-name", + "veadk-studio", + "--public-domain", + "studio.example.com", + "--vestack-openapi-url", + "https://openapi.example.com", + "--vestack-agentkit-region", + "region-a", + "--vestack-apig-cluster-id", + "cluster-1", + "--byteplus-access-key", + "ak", + "--byteplus-secret-key", + "sk", + ], + ) + + assert result.exit_code == 1 + assert "requires --provider volcengine" in result.output + + +def test_vestack_cli_validates_complete_hermes_model_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "veadk.cli.cli_frontend._resolve_or_create_studio_identity_resources", + lambda **_: ("pool-1", "auth.example.com", "client-1"), + ) + result = CliRunner().invoke( + studio, + [ + "deploy", + "--deploy-target", + "vestack", + "--provider", + "volcengine", + "--image", + "registry.example.com/studio:v1", + "--vefaas-app-name", + "veadk-studio", + "--public-domain", + "studio.example.com", + "--vestack-openapi-url", + "https://openapi.example.com", + "--vestack-agentkit-region", + "region-a", + "--vestack-apig-cluster-id", + "cluster-1", + "--user-pool-id", + "pool-1", + "--allowed-client-id", + "client-1", + "--iam-role", + "trn:iam::1:role/studio", + "--volcengine-access-key", + "ak", + "--volcengine-secret-key", + "sk", + "--vestack-hermes-model-agent-name", + "model-agent", + ], + ) + + assert result.exit_code == 1 + assert "require complete model environment" in result.output diff --git a/tests/cli/test_vestack_studio_image.py b/tests/cli/test_vestack_studio_image.py new file mode 100644 index 000000000..3032f2d50 --- /dev/null +++ b/tests/cli/test_vestack_studio_image.py @@ -0,0 +1,50 @@ +from pathlib import Path + +from veadk.utils.cloud_provider import ( + apig_openapi_host, + configure_openapi_tls, + iam_openapi_host, + vefaas_openapi_host, +) + + +def test_vestack_control_plane_hosts_are_overridable(monkeypatch) -> None: + monkeypatch.setenv("VEFAAS_OPENAPI_HOST", "http://top.vestack.cloud/") + monkeypatch.setenv("APIG_OPENAPI_HOST", "top.vestack.cloud") + monkeypatch.setenv("IAM_OPENAPI_HOST", "https://top.vestack.cloud/") + + assert vefaas_openapi_host("cn-bj", "volcengine") == "top.vestack.cloud" + assert apig_openapi_host("cn-bj", "volcengine") == "top.vestack.cloud" + assert iam_openapi_host("volcengine") == "top.vestack.cloud" + + +def test_vestack_control_plane_tls_is_explicit(monkeypatch) -> None: + class Configuration: + ssl_ca_cert = "" + verify_ssl = True + + configuration = Configuration() + monkeypatch.setenv("VOLCENGINE_OPENAPI_CA_BUNDLE", "/etc/ssl/private-ca.pem") + monkeypatch.setenv("VOLCENGINE_OPENAPI_VERIFY_SSL", "false") + configure_openapi_tls(configuration) + assert configuration.ssl_ca_cert == "/etc/ssl/private-ca.pem" + assert configuration.verify_ssl is True + + monkeypatch.delenv("VOLCENGINE_OPENAPI_CA_BUNDLE") + configure_openapi_tls(configuration) + assert configuration.verify_ssl is False + + +def test_vestack_image_exposes_platform_entrypoint_and_healthcheck() -> None: + root = Path(__file__).resolve().parents[2] + dockerfile = (root / "docker" / "Dockerfile.vestack").read_text() + entrypoint = (root / "docker" / "vestack-entrypoint.sh").read_text() + + assert "EXPOSE 8000" in dockerfile + assert "COPY docker/vestack-entrypoint.sh /app/run.sh" in dockerfile + assert 'ENTRYPOINT ["/app/run.sh"]' in dockerfile + assert "127.0.0.1:8000/ping" in dockerfile + assert "VOLCENGINE_AGENTKIT_HOST:=top.vestack.cloud" in entrypoint + assert "VEADK_STUDIO_AUTH_MODE:=gateway" in entrypoint + assert "VEADK_STUDIO_LISTEN_PORT" in entrypoint + assert '--auth-mode "${VEADK_STUDIO_AUTH_MODE}"' in entrypoint diff --git a/tests/frontend/server/test_agentkit_clients.py b/tests/frontend/server/test_agentkit_clients.py new file mode 100644 index 000000000..c4835ea7c --- /dev/null +++ b/tests/frontend/server/test_agentkit_clients.py @@ -0,0 +1,21 @@ +from agentkit.sdk.tools.client import AgentkitToolsClient + +from frontend.server.agentkit_clients import create_agentkit_client + + +def test_tools_client_preserves_vestack_endpoint_from_environment(monkeypatch) -> None: + monkeypatch.setenv("VOLCENGINE_AGENTKIT_HOST", "agentkit.e70.inspirecloud.io") + monkeypatch.setenv("VOLCENGINE_AGENTKIT_SCHEME", "http") + + client = create_agentkit_client( + AgentkitToolsClient, + provider="volcengine", + access_key="temporary-ak", + secret_key="temporary-sk", + session_token="temporary-token", + region="e70", + ) + + assert client.host == "agentkit.e70.inspirecloud.io" + assert client.scheme == "http" + assert client.region == "e70" diff --git a/tests/integrations/test_ve_apig_vestack.py b/tests/integrations/test_ve_apig_vestack.py new file mode 100644 index 000000000..00541f813 --- /dev/null +++ b/tests/integrations/test_ve_apig_vestack.py @@ -0,0 +1,227 @@ +import json +from types import SimpleNamespace +from unittest.mock import MagicMock + +from veadk.integrations.ve_apig.ve_apig import APIGateway + + +def _thread(result): + thread = MagicMock() + thread.get.return_value = result + return thread + + +def test_create_vestack_gateway_uses_cluster_native_request_shape() -> None: + apig = APIGateway.__new__(APIGateway) + apig.region = "e70" + apig.apig_client = MagicMock() + apig.apig_client.create_gateway.return_value = _thread( + SimpleNamespace(to_dict=lambda: {"id": "gw-1"}) + ) + apig.apig_client.list_gateways.return_value = _thread( + SimpleNamespace( + items=[SimpleNamespace(to_dict=lambda: {"id": "gw-1", "status": "Running"})] + ) + ) + + gateway_id = apig.create_serverless_gateway( + "studio-gateway", + vestack_cluster_id="cluster-1", + vestack_namespace="studio-apig", + vestack_cluster_name="aio", + ) + + assert gateway_id == "gw-1" + request = apig.apig_client.create_gateway.call_args.args[0] + assert request.cluster_spec == { + "ClusterId": "cluster-1", + "Namespace": "studio-apig", + "ClusterName": "aio", + } + assert request.resource_spec["Replicas"] == 2 + assert request.attribute_map["cluster_spec"] == "ClusterSpec" + + +def test_create_vestack_gateway_requires_namespace() -> None: + apig = APIGateway.__new__(APIGateway) + apig.region = "e70" + + try: + apig.create_serverless_gateway( + "studio-gateway", + vestack_cluster_id="cluster-1", + ) + except ValueError as error: + assert str(error) == "VeStack APIG namespace is required" + else: + raise AssertionError("missing VeStack namespace must fail") + + +def test_create_public_gateway_keeps_sdk_resource_spec() -> None: + apig = APIGateway.__new__(APIGateway) + apig.region = "cn-beijing" + apig.apig_client = MagicMock() + apig.apig_client.create_gateway.return_value = _thread( + SimpleNamespace(to_dict=lambda: {"id": "gw-public"}) + ) + apig.apig_client.list_gateways.return_value = _thread( + SimpleNamespace( + items=[ + SimpleNamespace( + to_dict=lambda: {"id": "gw-public", "status": "Running"} + ) + ] + ) + ) + + assert apig.create_serverless_gateway("studio-gateway") == "gw-public" + + request = apig.apig_client.create_gateway.call_args.args[0] + assert request.resource_spec.replicas == 2 + assert request.resource_spec.network_type == { + "EnablePublicNetwork": True, + "EnablePrivateNetwork": False, + } + + +def test_get_vestack_gateway_external_http_address_restores_transport() -> None: + apig = APIGateway.__new__(APIGateway) + response = SimpleNamespace( + data=json.dumps( + { + "Result": { + "Gateway": { + "GatewayExternalAddress": { + "IPs": ["192.0.2.10"], + "Items": [ + {"Protocol": "HTTPS", "Port": 443}, + {"Protocol": "HTTP", "Port": 32968}, + ], + } + } + } + } + ).encode() + ) + original_request = MagicMock(return_value=response) + rest_client = SimpleNamespace(request=original_request) + apig.apig_client = SimpleNamespace( + api_client=SimpleNamespace(rest_client=rest_client), + ) + apig.apig_client.get_gateway = MagicMock( + side_effect=lambda _request: rest_client.request("GET", "/gateway") + ) + + assert apig.get_gateway_external_http_address("gw-1") == ( + "192.0.2.10", + 32968, + ) + assert rest_client.request is original_request + + +def test_get_vestack_gateway_external_http_address_requires_http_listener() -> None: + apig = APIGateway.__new__(APIGateway) + response = SimpleNamespace( + data=json.dumps( + { + "Result": { + "Gateway": { + "GatewayExternalAddress": { + "IPs": ["192.0.2.10"], + "Items": [{"Protocol": "HTTPS", "Port": 443}], + } + } + } + } + ) + ) + rest_client = SimpleNamespace(request=MagicMock(return_value=response)) + apig.apig_client = SimpleNamespace( + api_client=SimpleNamespace(rest_client=rest_client), + ) + apig.apig_client.get_gateway = MagicMock( + side_effect=lambda _request: rest_client.request("GET", "/gateway") + ) + + try: + apig.get_gateway_external_http_address("gw-1") + except RuntimeError as error: + assert "external HTTP address" in str(error) + else: + raise AssertionError("missing HTTP listener must fail") + + +def test_create_vestack_service_uses_service_domain_spec() -> None: + apig = APIGateway.__new__(APIGateway) + apig.apig_client = MagicMock() + apig.apig_client.create_gateway_service.return_value = _thread( + SimpleNamespace(to_dict=lambda: {"id": "svc-1"}) + ) + + service_id = apig.create_gateway_service( + "gw-1", + "studio-service", + custom_domain="studio.example.com", + vestack=True, + ) + + assert service_id == "svc-1" + request = apig.apig_client.create_gateway_service.call_args.args[0] + assert request.service_domain_spec == [ + {"Domain": "studio.example.com", "Protocol": ["HTTP"]} + ] + assert request.attribute_map["service_domain_spec"] == "ServiceDomainSpec" + + +def test_create_vestack_service_requires_domain() -> None: + apig = APIGateway.__new__(APIGateway) + apig.apig_client = MagicMock() + + try: + apig.create_gateway_service("gw-1", "studio-service", vestack=True) + except ValueError as error: + assert str(error) == "VeStack APIG service requires a domain" + else: + raise AssertionError("missing VeStack service domain must fail") + + +def test_create_public_service_keeps_sdk_custom_domain() -> None: + apig = APIGateway.__new__(APIGateway) + apig.apig_client = MagicMock() + apig.apig_client.create_gateway_service.return_value = _thread( + SimpleNamespace(to_dict=lambda: {"id": "svc-public"}) + ) + + service_id = apig.create_gateway_service( + "gw-1", + "studio-service", + custom_domain="studio.example.com", + ) + + assert service_id == "svc-public" + request = apig.apig_client.create_gateway_service.call_args.args[0] + assert request.custom_domains[0].domain == "studio.example.com" + assert not hasattr(request, "service_domain_spec") + + +def test_find_helpers_return_only_named_resources() -> None: + missing = SimpleNamespace(name="other") + service = SimpleNamespace(name="studio-service") + upstream = SimpleNamespace(name="studio-upstream") + route = SimpleNamespace(name="studio-route") + apig = APIGateway.__new__(APIGateway) + apig.apig_client = MagicMock() + apig.apig_20221112_client = MagicMock() + apig.apig_client.list_gateway_services.return_value = SimpleNamespace( + items=[missing, service] + ) + apig.apig_client.list_upstreams.return_value = SimpleNamespace( + items=[missing, upstream] + ) + apig.apig_20221112_client.list_routes.return_value = SimpleNamespace( + items=[missing, route] + ) + + assert apig.find_gateway_service("gw-1", "studio-service") is service + assert apig.find_upstream("gw-1", "studio-upstream") is upstream + assert apig.find_route("svc-1", "studio-route") is route diff --git a/tests/integrations/test_ve_faas_image.py b/tests/integrations/test_ve_faas_image.py new file mode 100644 index 000000000..ced3b933c --- /dev/null +++ b/tests/integrations/test_ve_faas_image.py @@ -0,0 +1,48 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from veadk.integrations.ve_faas.ve_faas import VeFaaS + + +def test_create_image_function_binds_configured_iam_role( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = MagicMock() + client.create_function.return_value = SimpleNamespace( + id="function-id", + project_name="default", + ) + fake = object.__new__(VeFaaS) + fake.client = client + fake.project_name = "default" + monkeypatch.setenv( + "IAM_ROLE", + "trn:iam::123:role/VeADKFrontendServiceRole", + ) + monkeypatch.setattr( + "veadk.config.veadk_environments", + {"CLOUD_PROVIDER": "volcengine"}, + ) + + name, function_id = VeFaaS._create_image_function( + fake, + "studio-function", + "registry.example.com/studio:latest", + ) + + request = client.create_function.call_args.args[0] + assert name == "studio-function" + assert function_id == "function-id" + assert request.command == "bash ./run.sh" + assert request.role == "trn:iam::123:role/VeADKFrontendServiceRole" + assert request.project_name == "default" diff --git a/veadk/auth/middleware/oauth2_auth.py b/veadk/auth/middleware/oauth2_auth.py index 6db3b9e3e..4478bdbe6 100644 --- a/veadk/auth/middleware/oauth2_auth.py +++ b/veadk/auth/middleware/oauth2_auth.py @@ -71,6 +71,7 @@ import hmac import json import logging +import os import random import secrets import time @@ -496,8 +497,18 @@ def from_veidentity( except Exception as e: logger.warning("Callback registration skipped (may exist): %s", e) - # Step 4: Fetch OIDC discovery configuration - base_url = f"https://{user_pool_domain}" + # Step 4: Fetch OIDC discovery configuration. Public-cloud user pools + # expose discovery at the domain root over HTTPS. VeStack installations + # may expose it over HTTP under /userpool/; allow deploy-time + # configuration without changing the provider-returned OAuth endpoints. + base_url_template = os.getenv("VEIDENTITY_OIDC_BASE_URL", "").strip() + if base_url_template: + base_url = base_url_template.format( + user_pool_uid=user_pool_id, + user_pool_domain=user_pool_domain, + ).rstrip("/") + else: + base_url = f"https://{user_pool_domain}" oidc_config = _fetch_oidc_discovery(base_url) # Step 5: Build OAuth2Config from discovered endpoints diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py index 9be59a43f..4f24b739f 100644 --- a/veadk/cli/cli_frontend.py +++ b/veadk/cli/cli_frontend.py @@ -101,6 +101,15 @@ "BYTEPLUS_SECRET_KEY", "BYTEPLUS_SESSION_TOKEN", "IAM_ROLE", + "VEFAAS_OPENAPI_HOST", + "VEFAAS_OPENAPI_SCHEME", + "APIG_OPENAPI_HOST", + "APIG_OPENAPI_SCHEME", + "IAM_OPENAPI_HOST", + "IAM_OPENAPI_SCHEME", + "IDENTITY_OPENAPI_HOST", + "IDENTITY_OPENAPI_SCHEME", + "VOLCENGINE_OPENAPI_VERIFY_SSL", ) _RUNTIME_UPDATE_CAPABILITY_INITIAL_WAIT_SECONDS = 1.8 _RUNTIME_UPDATE_CAPABILITY_CACHE_TTL_SECONDS = 5 * 60.0 @@ -2221,6 +2230,10 @@ def _require_agent_management(request: Request) -> StudioPrincipal | None: from frontend.server.skills.devenv import mount_skill_workbench_routes from frontend.server.skills.models import SkillIdentity + is_vestack_deployment = ( + os.getenv("VEADK_STUDIO_DEPLOY_TARGET") or "" + ).strip().lower() == "vestack" + def _skill_identity(request: Request) -> SkillIdentity: principal = _current_principal(request) if access_policy.enabled and principal is None: @@ -2247,8 +2260,9 @@ def _skill_workbench_tools_client(region: str): region=region, session_token=session_token or "", ) - if provider != "byteplus": + if provider != "byteplus" and not is_vestack_deployment: client.set_host("open.volcengineapi.com") + client.host = "open.volcengineapi.com" return client def _skill_workbench_skills_client(region: str): @@ -2649,8 +2663,9 @@ def _sandbox_client(region: str): region=resolved_region, session_token=session_token or "", ) - if provider != "byteplus": + if provider != "byteplus" and not is_vestack_deployment: client.set_host("open.volcengineapi.com") + client.host = "open.volcengineapi.com" return client from frontend.server.studio_tools import ( @@ -2742,7 +2757,17 @@ def _migration_creator(request: Request) -> str: project_service=intelligent_project_service, ) - sandbox_gateway = AgentkitSandboxGateway( + if is_vestack_deployment: + from veadk.cli.frontend_sandbox_managed_tool_vestack import ( + VeStackAgentkitSandboxGateway, + VeStackManagedToolSpec, + ) + + sandbox_gateway_class = VeStackAgentkitSandboxGateway + else: + VeStackManagedToolSpec = None + sandbox_gateway_class = AgentkitSandboxGateway + sandbox_gateway = sandbox_gateway_class( _sandbox_client, region_candidates=sandbox_region_candidates( os.getenv("AGENTKIT_SANDBOX_REGION"), @@ -2766,11 +2791,83 @@ def _migration_creator(request: Request) -> str: snapshot_tool_id=None, agent_kind="intelligent-development", ) + codex_managed_tool_spec = None + if is_vestack_deployment and ( + os.getenv("VEADK_STUDIO_CODEX_TOOL_PER_AGENT") or "" + ).strip().lower() in { + "1", + "true", + "yes", + "on", + }: + role_name = (os.getenv("VEADK_STUDIO_CODEX_ROLE_NAME") or "").strip() + if role_name: + codex_managed_tool_spec = VeStackManagedToolSpec( + tool_type="CodeEnv", + role_name=role_name, + project_name=(os.getenv("VEADK_STUDIO_PROJECT") or "default").strip(), + cpu_milli=int(os.getenv("VEADK_STUDIO_CODEX_CPU_MILLI") or "2000"), + memory_mb=int(os.getenv("VEADK_STUDIO_CODEX_MEMORY_MB") or "4096"), + port=int(os.getenv("VEADK_STUDIO_CODEX_PORT") or "8642"), + disk_gb=int(os.getenv("VEADK_STUDIO_CODEX_DISK_GB") or "10"), + ) + else: + logger.warning( + "Codex Tool-per-Agent is enabled but the IAM role is missing." + ) sandbox_service = SandboxConversationService( sandbox_gateway, tool_id=sandbox_chat_codex_tool_id, snapshot_tool_id=sandbox_chat_codex_snapshot_tool_id, + managed_tool_spec=codex_managed_tool_spec, ) + hermes_managed_tool_spec = None + if is_vestack_deployment and ( + os.getenv("VEADK_STUDIO_HERMES_TOOL_PER_AGENT") or "" + ).strip().lower() in { + "1", + "true", + "yes", + "on", + }: + model_agent_name = ( + os.getenv("VEADK_STUDIO_HERMES_MODEL_AGENT_NAME") or "" + ).strip() + model_agent_api_base = ( + os.getenv("VEADK_STUDIO_HERMES_MODEL_API_BASE") or "" + ).strip() + model_agent_api_key = ( + os.getenv("VEADK_STUDIO_HERMES_MODEL_API_KEY") or "" + ).strip() + model_agent_model_id = (os.getenv("VEADK_STUDIO_HERMES_MODEL_ID") or "").strip() + role_name = (os.getenv("VEADK_STUDIO_HERMES_ROLE_NAME") or "").strip() + if all( + ( + model_agent_name, + model_agent_api_base, + model_agent_api_key, + model_agent_model_id, + role_name, + ) + ): + hermes_managed_tool_spec = VeStackManagedToolSpec( + tool_type="Station-Hermes", + model_agent_name=model_agent_name, + model_agent_api_base=model_agent_api_base, + model_agent_api_key=model_agent_api_key, + model_agent_model_id=model_agent_model_id, + role_name=role_name, + project_name=(os.getenv("VEADK_STUDIO_PROJECT") or "default").strip(), + cpu_milli=int(os.getenv("VEADK_STUDIO_HERMES_CPU_MILLI") or "2000"), + memory_mb=int(os.getenv("VEADK_STUDIO_HERMES_MEMORY_MB") or "4096"), + port=int(os.getenv("VEADK_STUDIO_HERMES_PORT") or "4500"), + disk_gb=int(os.getenv("VEADK_STUDIO_HERMES_DISK_GB") or "10"), + ) + else: + logger.warning( + "Hermes Tool-per-Agent is enabled but its ModelCenter Endpoint " + "or role is missing." + ) sandbox_agent_services = { "agentkit-cli": SandboxAgentSessionService( sandbox_gateway, @@ -2801,6 +2898,22 @@ def _migration_creator(request: Request) -> str: kind="hermes", tool_id=sandbox_chat_hermes_tool_id, snapshot_tool_id=sandbox_chat_hermes_snapshot_tool_id, + managed_tool_spec=hermes_managed_tool_spec, + surface_path="/proxy/4500/", + surface_start_command=( + "if ! curl -fsS --max-time 2 http://127.0.0.1:4500/ " + ">/dev/null 2>&1; then " + "nohup /home/gem/.local/bin/hermes dashboard " + "--host 127.0.0.1 --port 4500 --no-open " + ">/home/gem/.hermes/dashboard.log 2>&1 & fi" + ), + surface_ready_path="/proxy/4500/", + unconfigured_message=( + "管理员未配置 Hermes 模型或 IAM Role。" + if hermes_managed_tool_spec is None + and (os.getenv("VEADK_STUDIO_HERMES_TOOL_PER_AGENT") or "").strip() + else "" + ), ), } @@ -13312,6 +13425,112 @@ def _resolve_studio_cloud_credentials( @studio.command("deploy") +@click.option( + "--deploy-target", + type=click.Choice(["application", "vestack"]), + default="application", + show_default=True, + envvar="VEADK_STUDIO_DEPLOY_TARGET", + help="Use public-cloud VeFaaS Application or VeStack image Function + APIG.", +) +@click.option( + "--image", + default="", + envvar="VEADK_STUDIO_IMAGE", + help="Prebuilt linux/amd64 Studio image required by --deploy-target vestack.", +) +@click.option( + "--public-domain", + default="", + envvar="VEADK_STUDIO_PUBLIC_DOMAIN", + help="Dedicated APIG host for the VeStack Studio deployment.", +) +@click.option( + "--vestack-openapi-url", + default="", + envvar="VESTACK_OPENAPI_URL", + help="VeStack control-plane URL, for example https://openapi.example.com.", +) +@click.option( + "--vestack-agentkit-host", + default="ops-top.ops-top.svc:8000", + show_default=True, + envvar="VESTACK_AGENTKIT_HOST", + help="AgentKit TOP host reachable from the VeFaaS data plane.", +) +@click.option( + "--vestack-agentkit-region", + default="", + envvar="VESTACK_AGENTKIT_REGION", + help="Delivered environment region id, for example e70.", +) +@click.option( + "--vestack-codex-port", + type=click.IntRange(1, 65535), + default=8642, + show_default=True, + envvar="VEADK_STUDIO_CODEX_PORT", + help="Code Sandbox service port used by independent VeStack Codex Tools.", +) +@click.option( + "--vestack-hermes-port", + type=click.IntRange(1, 65535), + default=4500, + show_default=True, + envvar="VEADK_STUDIO_HERMES_PORT", + help="Hermes dashboard port used by independent VeStack Hermes Tools.", +) +@click.option( + "--vestack-hermes-model-agent-name", + default="", + envvar="VEADK_STUDIO_HERMES_MODEL_AGENT_NAME", + help="Default AgentKit model endpoint name for independent VeStack Hermes Tools.", +) +@click.option( + "--vestack-hermes-model-api-base", + default="", + envvar="VEADK_STUDIO_HERMES_MODEL_API_BASE", + help="Model API base injected into independent VeStack Hermes Tools.", +) +@click.option( + "--vestack-hermes-model-api-key", + default="", + envvar="VEADK_STUDIO_HERMES_MODEL_API_KEY", + help="Model API key injected into independent VeStack Hermes Tools; prefer envvar.", +) +@click.option( + "--vestack-hermes-model-id", + default="", + envvar="VEADK_STUDIO_HERMES_MODEL_ID", + help="Model endpoint ID injected into independent VeStack Hermes Tools.", +) +@click.option( + "--vestack-apig-cluster-id", + default="", + envvar="VESTACK_APIG_CLUSTER_ID", + help="VeStack cluster ID used by APIG ClusterSpec.", +) +@click.option( + "--vestack-apig-namespace", + default="veadk-studio-apig", + show_default=True, + envvar="VESTACK_APIG_NAMESPACE", + help="Existing Kubernetes namespace where APIG installs the gateway.", +) +@click.option( + "--vestack-apig-cluster-name", + default="aio", + show_default=True, + envvar="VESTACK_APIG_CLUSTER_NAME", + help="VeStack APIG cluster display name.", +) +@click.option( + "--vestack-insecure-skip-tls-verify", + is_flag=True, + default=False, + envvar="VESTACK_INSECURE_SKIP_TLS_VERIFY", + help="Explicitly allow a private/self-signed OpenAPI certificate.", +) @click.option( "--user-pool-id", default=None, @@ -13541,6 +13760,22 @@ def _resolve_studio_cloud_credentials( help="TOS object prefix for the Studio main release channel.", ) def frontend_deploy( + deploy_target: str, + image: str, + public_domain: str, + vestack_openapi_url: str, + vestack_agentkit_host: str, + vestack_agentkit_region: str, + vestack_codex_port: int, + vestack_hermes_port: int, + vestack_hermes_model_agent_name: str, + vestack_hermes_model_api_base: str, + vestack_hermes_model_api_key: str, + vestack_hermes_model_id: str, + vestack_apig_cluster_id: str, + vestack_apig_namespace: str, + vestack_apig_cluster_name: str, + vestack_insecure_skip_tls_verify: bool, user_pool_id: str | None, allowed_client_id: str | None, client_secret: str, @@ -13679,6 +13914,208 @@ def frontend_deploy( if session_token: os.environ["BYTEPLUS_SESSION_TOKEN"] = session_token + if deploy_target == "vestack": + from urllib.parse import urlparse + + if provider_id != "volcengine": + raise click.ClickException( + "VeStack Studio deploy requires --provider volcengine." + ) + parsed_openapi = urlparse(vestack_openapi_url.strip()) + if not parsed_openapi.scheme or not parsed_openapi.netloc: + raise click.ClickException( + "--vestack-openapi-url must include scheme and host." + ) + if not image.strip(): + raise click.ClickException("--image is required for VeStack Studio deploy.") + if not public_domain.strip(): + raise click.ClickException( + "--public-domain is required for VeStack Studio deploy." + ) + if not vestack_agentkit_region.strip(): + raise click.ClickException( + "--vestack-agentkit-region is required for VeStack Studio deploy." + ) + if not vestack_apig_cluster_id.strip(): + raise click.ClickException( + "--vestack-apig-cluster-id is required for VeStack Studio deploy." + ) + + for service_name in ("VEFAAS", "APIG", "IAM", "IDENTITY"): + os.environ[f"{service_name}_OPENAPI_HOST"] = parsed_openapi.netloc + os.environ[f"{service_name}_OPENAPI_SCHEME"] = parsed_openapi.scheme + if vestack_insecure_skip_tls_verify: + os.environ["VOLCENGINE_OPENAPI_VERIFY_SSL"] = "false" + + identity_region = region + user_pool_id, user_pool_domain, allowed_client_id = ( + _resolve_or_create_studio_identity_resources( + access_key=ak, + secret_key=sk, + session_token=session_token, + user_pool_id=user_pool_id, + client_id=allowed_client_id, + application_name=vefaas_app_name, + region=identity_region, + provider=provider_id, + ) + ) + + if iam_role: + role_trn = iam_role + click.echo(f"Using provided IAM role: {role_trn}") + else: + from veadk.cli.frontend_deploy_iam_vestack import ( + ensure_frontend_role_vestack, + ) + + click.echo("Ensuring VeStack Studio IAM role + policy…") + role_trn = ensure_frontend_role_vestack( + ak, + sk, + session_token=session_token, + provider=provider_id, + ) + + endpoint = f"http://{public_domain.strip().rstrip('/')}" + redirect_uri = f"{endpoint}/oauth2/callback" + vestack_environment = { + "CLOUD_PROVIDER": "volcengine", + "AGENTKIT_CLOUD_PROVIDER": "volcengine", + "REGION": vestack_agentkit_region, + "VOLCENGINE_REGION": vestack_agentkit_region, + "AGENTKIT_SANDBOX_REGION": vestack_agentkit_region, + "VOLCENGINE_AGENTKIT_HOST": vestack_agentkit_host, + "VOLCENGINE_AGENTKIT_SCHEME": "http", + "VOLCENGINE_AGENTKIT_REGION": vestack_agentkit_region, + "VOLCENGINE_AGENTKIT_SERVICE": "agentkit", + "IDENTITY_OPENAPI_HOST": vestack_agentkit_host, + "IDENTITY_OPENAPI_SCHEME": "http", + "VEIDENTITY_OIDC_BASE_URL": ( + f"http://{user_pool_domain}/userpool/{user_pool_id}" + ), + "OAUTH2_USER_POOL_ID": str(user_pool_id), + "OAUTH2_USER_POOL_CLIENT_ID": str(allowed_client_id), + "OAUTH2_PROVIDER": "veidentity", + "OAUTH2_REDIRECT_URI": redirect_uri, + "VEIDENTITY_REGION": identity_region, + "VEADK_STUDIO_AUTH_MODE": "frontend", + "VEADK_STUDIO_DEPLOY_TARGET": "vestack", + "VEADK_STUDIO_USER_POOL_ID": str(user_pool_id), + "VEADK_STUDIO_DEPLOY_REGION": vestack_agentkit_region, + "VEADK_STUDIO_PROJECT": project, + "VEADK_STUDIO_CODEX_TOOL_PER_AGENT": "true", + "VEADK_STUDIO_CODEX_ROLE_NAME": role_trn.rsplit("/", 1)[-1], + "VEADK_STUDIO_CODEX_PORT": str(vestack_codex_port), + "VEADK_STUDIO_HERMES_PORT": str(vestack_hermes_port), + } + if vestack_hermes_model_agent_name.strip(): + missing_hermes_model_options = [ + option + for option, value in ( + ("--vestack-hermes-model-api-base", vestack_hermes_model_api_base), + ("--vestack-hermes-model-api-key", vestack_hermes_model_api_key), + ("--vestack-hermes-model-id", vestack_hermes_model_id), + ) + if not value.strip() + ] + if missing_hermes_model_options: + raise click.ClickException( + "Independent VeStack Hermes Tools require complete model " + "environment: " + ", ".join(missing_hermes_model_options) + ) + vestack_environment.update( + { + "VEADK_STUDIO_HERMES_TOOL_PER_AGENT": "true", + "VEADK_STUDIO_HERMES_MODEL_AGENT_NAME": ( + vestack_hermes_model_agent_name.strip() + ), + "VEADK_STUDIO_HERMES_MODEL_API_BASE": ( + vestack_hermes_model_api_base.strip().rstrip("/") + ), + "VEADK_STUDIO_HERMES_MODEL_API_KEY": ( + vestack_hermes_model_api_key.strip() + ), + "VEADK_STUDIO_HERMES_MODEL_ID": vestack_hermes_model_id.strip(), + "VEADK_STUDIO_HERMES_ROLE_NAME": role_trn.rsplit("/", 1)[-1], + "SANDBOX_CHAT_HERMES": "", + "SANDBOX_CHAT_HERMES_SNAPSHOT": "", + } + ) + if client_secret: + vestack_environment["OAUTH2_CLIENT_SECRET"] = client_secret + if site_title is not None: + vestack_environment["VEADK_SITE_TITLE"] = branding_title + if studio_admins: + vestack_environment["VEADK_STUDIO_ADMINS"] = studio_admins + if studio_developers: + vestack_environment["VEADK_STUDIO_DEVELOPERS"] = studio_developers + configured_sandbox_tools = { + "SANDBOX_DEV": sandbox_dev_tool_id, + "SANDBOX_CHAT_CODEX": sandbox_chat_codex_tool_id, + "SANDBOX_CHAT_OPENCLAW": sandbox_chat_openclaw_tool_id, + "SANDBOX_CHAT_CODEX_SNAPSHOT": sandbox_chat_codex_snapshot_tool_id, + "SANDBOX_CHAT_OPENCLAW_SNAPSHOT": (sandbox_chat_openclaw_snapshot_tool_id), + } + vestack_environment.update( + { + key: str(value).strip() + for key, value in configured_sandbox_tools.items() + if str(value or "").strip() + } + ) + + from veadk.cli.vestack_studio_deploy import deploy_vestack_studio_image + + click.echo("Deploying Studio as VeStack VeFaaS Function + APIG…") + deployment = deploy_vestack_studio_image( + access_key=ak, + secret_key=sk, + session_token=session_token, + region=region, + project=project, + application_name=vefaas_app_name, + image=image, + role_trn=role_trn, + public_domain=public_domain, + gateway_name=gateway_name, + gateway_service_name=gateway_service_name, + gateway_upstream_name=gateway_upstream_name, + apig_cluster_id=vestack_apig_cluster_id, + apig_namespace=vestack_apig_namespace, + apig_cluster_name=vestack_apig_cluster_name, + environment=vestack_environment, + ) + + endpoint = deployment.endpoint + redirect_uri = f"{endpoint}/oauth2/callback" + + from veadk.integrations.ve_identity.identity_client import IdentityClient + + IdentityClient( + access_key=ak, + secret_key=sk, + session_token=session_token, + region=identity_region, + provider=provider_id, + enable_vefaas_iam_fallback=False, + ).register_callback_for_user_pool_client( + user_pool_uid=str(user_pool_id), + client_uid=str(allowed_client_id), + callback_url=redirect_uri, + web_origin=endpoint, + dismiss_login_page_enabled=False, + skip_consent_enabled=True, + ) + click.echo(f"✅ VeStack Studio Function deployed: {deployment.endpoint}") + click.echo(f" function id: {deployment.function_id}") + click.echo(f" gateway id: {deployment.gateway_id}") + click.echo(f" service id: {deployment.service_id}") + click.echo(f" upstream id: {deployment.upstream_id}") + click.echo(f" route id: {deployment.route_id}") + click.echo(f" IAM role: {role_trn}") + return + manage_user_pool_settings = not bool((user_pool_id or "").strip()) auto_identity_resources = not (user_pool_id and allowed_client_id) auto_storage = not str( diff --git a/veadk/cli/frontend_agent_proxy.py b/veadk/cli/frontend_agent_proxy.py index 4dfda8da0..64a65f80f 100644 --- a/veadk/cli/frontend_agent_proxy.py +++ b/veadk/cli/frontend_agent_proxy.py @@ -19,9 +19,10 @@ import asyncio import contextlib import html +import inspect import json import re -from collections.abc import Callable +from collections.abc import Awaitable, Callable from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit import httpx @@ -52,6 +53,51 @@ "/plugins", "/assets", ) +_HERMES_AIO_PATHS = ( + "/code-server", + "/terminal", + "/jupyter", + "/vnc", + "/proxy", + "/absproxy", + "/mcp", + "/v1", + "/llms.txt", + "/cdp", + "/tickets", + "/screenshot", + "/actions", + "/json", + "/devtools", + "/ws", + "/websockify", + "/static/sandbox", +) +_HERMES_DASHBOARD_PATHS = ( + "/achievements", + "/api", + "/assets", + "/channels", + "/chat", + "/config", + "/cron", + "/docs", + "/env", + "/files", + "/favicon.ico", + "/kanban", + "/logs", + "/mcp", + "/model", + "/models", + "/pairing", + "/plugins", + "/profiles", + "/sessions", + "/skills", + "/system", + "/webhooks", +) _BLOCKED_RESPONSE_HEADERS = { "connection", "content-encoding", @@ -88,11 +134,24 @@ def _rewrite_body( content_type: str, prefix: str, kind: str, + *, + upstream_path: str = "", ) -> bytes: if not any(marker in content_type for marker in _TEXT_CONTENT_TYPES): return body if kind in {"hermes", "deepseek-harness"}: body = _strip_hermes_gateway_query(body) + if kind == "hermes" and b"AIO Sandbox" not in body: + hermes_mount = _hermes_dashboard_mount(upstream_path) + if hermes_mount: + for path in _HERMES_DASHBOARD_PATHS: + body = _rewrite_root_path( + body, + path, + f"{prefix}{hermes_mount}{path}", + ) + for path in _HERMES_AIO_PATHS: + body = _rewrite_root_path(body, path, f"{prefix}{path}") if kind == "deepseek-harness": return _rewrite_deepseek_harness_body(body, content_type, prefix) source = f"/{kind}".encode() @@ -105,6 +164,15 @@ def _rewrite_body( return body +def _hermes_dashboard_mount(upstream_path: str) -> str: + """Return the AIO port-proxy mount hosting the Hermes Dashboard.""" + normalized = f"/{upstream_path.lstrip('/')}" + match = re.match(r"^/(absproxy|proxy)/(\d+)(?:/|$)", normalized) + if match is None: + return "" + return f"/{match.group(1)}/{match.group(2)}" + + def _rewrite_root_path(body: bytes, source_path: str, replacement_path: str) -> bytes: source = source_path.encode() replacement = replacement_path.encode() @@ -220,6 +288,23 @@ def _rewrite_location(location: str, prefix: str) -> str: return urlunsplit(("", "", f"{prefix}{parsed.path}", safe_query, parsed.fragment)) +def _websocket_origin(kind: str, path: str, target_url: str) -> str: + """Return the Origin expected by the upstream WebSocket service. + + AIO exposes arbitrary local ports below ``/proxy/``. Its HTTP proxy + accepts the public AgentKit origin, but the WebSocket target validates the + original local-service origin. Sending the AgentKit endpoint origin makes + Hermes reject the upgrade and the browser only sees close code 1006. + """ + normalized = f"/{path.lstrip('/')}" + if kind == "hermes": + match = re.match(r"/(?:abs)?proxy/(\d+)(?:/|$)", normalized) + if match is not None: + return f"http://localhost:{match.group(1)}" + parsed = urlsplit(target_url) + return f"{parsed.scheme}://{parsed.netloc}" + + def _rewrite_cookie(cookie: str, prefix: str) -> str: if re.search(r"(?i);\s*path=", cookie): return re.sub( @@ -232,7 +317,10 @@ def _rewrite_cookie(cookie: str, prefix: str) -> str: def mount_agent_surface_proxy_routes( app: object, - target_resolver: Callable[[str, str, str], SandboxProxyTarget], + target_resolver: Callable[ + [str, str, str], + SandboxProxyTarget | Awaitable[SandboxProxyTarget], + ], ) -> None: """Mount bounded HTTP and WebSocket proxies for branded agent WebUIs.""" @@ -248,7 +336,8 @@ async def _proxy_agent_webui( request: Request, ) -> Response: try: - target = target_resolver(kind, session_id, proxy_token) + resolved = target_resolver(kind, session_id, proxy_token) + target = await resolved if inspect.isawaitable(resolved) else resolved except (KeyError, PermissionError): return Response("沙箱页面授权已失效。", status_code=403) target_url = _target_url(target.endpoint, path, request.url.query) @@ -300,7 +389,13 @@ async def _proxy_agent_webui( response_headers["location"], prefix ) response = Response( - content=_rewrite_body(upstream.content, content_type, prefix, kind), + content=_rewrite_body( + upstream.content, + content_type, + prefix, + kind, + upstream_path=path, + ), status_code=upstream.status_code, headers=response_headers, media_type=None, @@ -323,8 +418,15 @@ async def _proxy_agent_websocket( from websockets.exceptions import WebSocketException try: - target = target_resolver(kind, session_id, proxy_token) + resolved = target_resolver(kind, session_id, proxy_token) + target = await resolved if inspect.isawaitable(resolved) else resolved except (KeyError, PermissionError): + # Complete the WebSocket upgrade before returning an application-level + # capability error. VeFaaS treats a rejected upgrade as a transport + # failure and temporarily puts the entire Function route in backoff, + # which can make a single-instance Studio return 429 for unrelated + # HTTP requests. + await websocket.accept() await websocket.close(code=1008, reason="invalid capability") return target_http_url = _target_url(target.endpoint, path, websocket.url.query) @@ -346,7 +448,7 @@ async def _proxy_agent_websocket( try: async with websockets.connect( target_ws_url, - origin=f"{parsed.scheme}://{parsed.netloc}", + origin=_websocket_origin(kind, path, target_http_url), subprotocols=protocols or None, max_size=None, ) as upstream: @@ -384,4 +486,9 @@ async def _to_browser() -> None: except (OSError, TimeoutError, WebSocketException) as error: logger.warning("Managed agent WebUI proxy failed: %s", type(error).__name__) with contextlib.suppress(RuntimeError): + # The upstream failure belongs to this logical WebSocket, not to + # the Studio Function transport. Finish the browser-side upgrade + # before closing so the outer VeFaaS gateway does not back off the + # only Studio instance. + await websocket.accept() await websocket.close(code=1011) diff --git a/veadk/cli/frontend_deploy_iam.py b/veadk/cli/frontend_deploy_iam.py index 8ec324fad..b25d109bd 100644 --- a/veadk/cli/frontend_deploy_iam.py +++ b/veadk/cli/frontend_deploy_iam.py @@ -20,6 +20,8 @@ """ import json +import os +from collections.abc import Callable from typing import Any from veadk.cli.frontend_deploy_policy import ( @@ -146,28 +148,32 @@ def _verify_custom_policy(svc: Any, policy_name: str) -> None: ) -def ensure_frontend_role( +def _ensure_frontend_role( access_key: str, secret_key: str, role_name: str = DEFAULT_ROLE_NAME, policy_name: str = DEFAULT_POLICY_NAME, session_token: str = "", provider: CloudProvider = DEFAULT_CLOUD_PROVIDER, + *, + ensure_role_policies: Callable[[Any, str, str], None], ) -> str: - """Get-or-create the frontend's IAM role and return its TRN. - - IAM is a global service, so region is irrelevant here. Safe to call - repeatedly: an existing role is reused, and create/attach errors for - already-existing resources are tolerated. - """ from volcengine.iam.IamService import IamService svc = IamService() svc.set_ak(access_key) svc.set_sk(secret_key) svc.set_host(iam_openapi_host(provider)) - if provider == "byteplus": - svc.set_scheme("https") + svc.set_scheme(os.getenv("IAM_OPENAPI_SCHEME", "https").strip() or "https") + if ca_bundle := os.getenv("VOLCENGINE_OPENAPI_CA_BUNDLE", "").strip(): + svc.session.verify = ca_bundle + elif os.getenv("VOLCENGINE_OPENAPI_VERIFY_SSL", "").strip().lower() in { + "0", + "false", + "no", + "off", + }: + svc.session.verify = False if session_token: svc.set_session_token(session_token) _ensure_custom_policy(svc, policy_name) @@ -181,7 +187,7 @@ def ensure_frontend_role( trn = _role_trn(existing) if trn: logger.info(f"Reusing existing IAM role {role_name} ({trn})") - _ensure_role_policies(svc, role_name, policy_name) + ensure_role_policies(svc, role_name, policy_name) return trn # Create the role with the vefaas trust relationship. @@ -189,6 +195,7 @@ def ensure_frontend_role( svc.create_role( { "RoleName": role_name, + "DisplayName": role_name, "TrustPolicyDocument": json.dumps(FRONTEND_DEPLOY_TRUST_POLICY), "Description": "VeADK frontend VeFaaS runtime role", } @@ -201,11 +208,36 @@ def ensure_frontend_role( trn = _role_trn(_result(svc.get_role({"RoleName": role_name}))) if not trn: raise RuntimeError(f"Could not resolve TRN for role {role_name}") - _ensure_role_policies(svc, role_name, policy_name) + ensure_role_policies(svc, role_name, policy_name) logger.info(f"Ensured IAM role {role_name} ({trn})") return trn +def ensure_frontend_role( + access_key: str, + secret_key: str, + role_name: str = DEFAULT_ROLE_NAME, + policy_name: str = DEFAULT_POLICY_NAME, + session_token: str = "", + provider: CloudProvider = DEFAULT_CLOUD_PROVIDER, +) -> str: + """Get-or-create the public-cloud frontend IAM role and return its TRN. + + IAM is a global service, so region is irrelevant here. Safe to call + repeatedly: an existing role is reused, and every configured system policy + remains required. + """ + return _ensure_frontend_role( + access_key, + secret_key, + role_name, + policy_name, + session_token, + provider, + ensure_role_policies=_ensure_role_policies, + ) + + def ensure_default_frontend_role_policy( role: str, *, diff --git a/veadk/cli/frontend_deploy_iam_vestack.py b/veadk/cli/frontend_deploy_iam_vestack.py new file mode 100644 index 000000000..40c0295c1 --- /dev/null +++ b/veadk/cli/frontend_deploy_iam_vestack.py @@ -0,0 +1,92 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +"""VeStack IAM role provisioning for a VeFaaS-hosted Studio.""" + +from typing import Any + +from veadk.cli.frontend_deploy_iam import ( + DEFAULT_POLICY_NAME, + DEFAULT_ROLE_NAME, + _ensure_frontend_role, + _result, +) +from veadk.cli.frontend_deploy_policy import FRONTEND_DEPLOY_SYSTEM_POLICIES +from veadk.utils.cloud_provider import DEFAULT_CLOUD_PROVIDER, CloudProvider +from veadk.utils.logger import get_logger + +logger = get_logger(__name__) + + +def _ensure_role_policies_vestack( + svc: Any, + role_name: str, + policy_name: str, +) -> None: + """Attach policies available in the VeStack IAM policy catalog.""" + result = _result(svc.list_attached_role_policies({"RoleName": role_name})) + attached = { + policy["PolicyName"] + for policy in result.get("AttachedPolicyMetadata", []) + if policy.get("PolicyName") + } + if policy_name not in attached: + _result( + svc.attach_role_policy( + { + "RoleName": role_name, + "PolicyName": policy_name, + "PolicyType": "Custom", + } + ) + ) + for system_policy_name in FRONTEND_DEPLOY_SYSTEM_POLICIES: + if system_policy_name in attached: + continue + try: + _result( + svc.attach_role_policy( + { + "RoleName": role_name, + "PolicyName": system_policy_name, + "PolicyType": "System", + } + ) + ) + except Exception as error: + detail = str(error) + if "PolicyNotExist" not in detail and "Policy does not exist" not in detail: + raise + logger.warning( + "VeStack system policy %s is unavailable; the Studio custom " + "policy remains attached.", + system_policy_name, + ) + continue + logger.info( + "Attached VeStack system policy %s to role %s", + system_policy_name, + role_name, + ) + + +def ensure_frontend_role_vestack( + access_key: str, + secret_key: str, + role_name: str = DEFAULT_ROLE_NAME, + policy_name: str = DEFAULT_POLICY_NAME, + session_token: str = "", + provider: CloudProvider = DEFAULT_CLOUD_PROVIDER, +) -> str: + """Get or create the Studio role using VeStack policy availability rules.""" + return _ensure_frontend_role( + access_key, + secret_key, + role_name, + policy_name, + session_token, + provider, + ensure_role_policies=_ensure_role_policies_vestack, + ) diff --git a/veadk/cli/frontend_sandbox.py b/veadk/cli/frontend_sandbox.py index ce2af4087..cfbfe285f 100644 --- a/veadk/cli/frontend_sandbox.py +++ b/veadk/cli/frontend_sandbox.py @@ -20,6 +20,8 @@ import base64 import binascii import contextlib +import hashlib +import hmac import json import os import posixpath @@ -29,8 +31,9 @@ import uuid from collections.abc import AsyncIterator, Awaitable, Callable, Mapping from dataclasses import dataclass, field, replace -from typing import Annotated, Any, Protocol +from typing import TYPE_CHECKING, Annotated, Any, Protocol +import httpx from fastapi import File, Request, UploadFile from fastapi.responses import JSONResponse, StreamingResponse @@ -66,6 +69,7 @@ CodexTokenUsage, approval_decision_from_payload, permission_settings_from_payload, + sandbox_service_url, ) from veadk.cli.frontend_sandbox_proxy import ( SANDBOX_UPLOAD_MAX_BYTES, @@ -82,10 +86,18 @@ logger = get_logger(__name__) +if TYPE_CHECKING: + from veadk.cli.frontend_sandbox_managed_tool_vestack import ( + VeStackManagedTool, + VeStackManagedToolSpec, + ) + STUDIO_SANDBOX_TOOL_NAME = "veadk-studio-codex" STUDIO_SANDBOX_TTL_SECONDS = 28_800 STUDIO_SANDBOX_MAX_ACTIVE = 20 STUDIO_SANDBOX_DISPLAY_NAME_MAX_LENGTH = SESSION_DISPLAY_NAME_MAX_LENGTH +STUDIO_SANDBOX_DISK_GB_MIN = 5 +STUDIO_SANDBOX_DISK_GB_MAX = 100 _SANDBOX_CHAT_TOOL_ENV = "SANDBOX_CHAT_CODEX" _SANDBOX_CHAT_SNAPSHOT_TOOL_ENV = "SANDBOX_CHAT_CODEX_SNAPSHOT" _SANDBOX_ENDPOINT_EXPORT_ENV = "STUDIO_EXPOSE_SANDBOX_ENDPOINT" @@ -109,6 +121,10 @@ "hermes": "SANDBOX_CHAT_HERMES_SNAPSHOT", } _SANDBOX_CODEX_AGENT_KIND = "codex" +_AGENT_SURFACE_READY_ATTEMPTS = 90 +_AGENT_SURFACE_READY_INTERVAL_SECONDS = 2 +_AGENT_SURFACE_SIGNING_KEY_ENV = "VEADK_STUDIO_KNOWLEDGE_SIGNING_KEY" +_AGENT_SURFACE_CAPABILITY_VERSION = "v1" _CREATE_SESSION_START_FAIL_CODE = "ErrCreateSessionFail" _SESSION_NOT_FOUND_CODE = "InvalidResource.NotFound" _ACTIVE_SESSION_STATUSES = {"creating", "pending", "running", "ready", "starting"} @@ -676,6 +692,19 @@ class SandboxCloudSnapshot: created_by: str = "" +def _managed_tool_disk_gb(value: object, default: int) -> int: + """Validate the persistent disk size used by an independent Tool.""" + disk_gb = default if value is None else value + if isinstance(disk_gb, bool) or not isinstance(disk_gb, int): + raise SandboxValidationError("diskGb 必须是整数。") + if not STUDIO_SANDBOX_DISK_GB_MIN <= disk_gb <= STUDIO_SANDBOX_DISK_GB_MAX: + raise SandboxValidationError( + "diskGb 必须在 " + f"{STUDIO_SANDBOX_DISK_GB_MIN} 到 {STUDIO_SANDBOX_DISK_GB_MAX} GiB 之间。" + ) + return disk_gb + + def _restorable_snapshots( sessions: list[SandboxCloudSession], snapshots: list[SandboxCloudSnapshot], @@ -762,6 +791,51 @@ def _session_matches_agent_kind( return include_legacy and not actual +def _agent_surface_capability(kind: str, session_id: str) -> str: + """Issue a replica-safe capability without exposing the cloud endpoint.""" + signing_key = os.getenv(_AGENT_SURFACE_SIGNING_KEY_ENV, "").strip() + if not signing_key: + return secrets.token_urlsafe(32) + expires_at = int(time.time()) + STUDIO_SANDBOX_TTL_SECONDS + payload = f"{_AGENT_SURFACE_CAPABILITY_VERSION}.{expires_at}" + message = f"veadk-agent-surface\0{kind}\0{session_id}\0{payload}".encode() + signature = hmac.new(signing_key.encode(), message, hashlib.sha256).digest() + encoded_signature = base64.urlsafe_b64encode(signature).decode().rstrip("=") + return f"{payload}.{encoded_signature}" + + +def _valid_agent_surface_capability( + token: str, + kind: str, + session_id: str, +) -> bool: + signing_key = os.getenv(_AGENT_SURFACE_SIGNING_KEY_ENV, "").strip() + if not signing_key or not token: + return False + try: + version, raw_expiry, encoded_signature = token.split(".", 2) + expires_at = int(raw_expiry) + except (TypeError, ValueError): + return False + now = int(time.time()) + if ( + version != _AGENT_SURFACE_CAPABILITY_VERSION + or expires_at < now + or expires_at > now + STUDIO_SANDBOX_TTL_SECONDS + 60 + ): + return False + payload = f"{version}.{expires_at}" + message = f"veadk-agent-surface\0{kind}\0{session_id}\0{payload}".encode() + expected = ( + base64.urlsafe_b64encode( + hmac.new(signing_key.encode(), message, hashlib.sha256).digest() + ) + .decode() + .rstrip("=") + ) + return secrets.compare_digest(encoded_signature, expected) + + @dataclass class SandboxConversation: """Server-side connection state for one reusable cloud Session.""" @@ -948,6 +1022,28 @@ async def get_tool(self, tool_id: str) -> Any: """Read one configured Sandbox Tool.""" raise NotImplementedError + async def list_managed_tools( + self, agent_kind: str, owner_id: str | None = None + ) -> list[VeStackManagedTool]: + """List Studio-created per-agent Tools, optionally by owner.""" + raise NotImplementedError # pragma: no cover - Protocol declaration + + async def create_managed_tool( + self, + spec: VeStackManagedToolSpec, + *, + display_name: str, + owner_id: str, + creator_name: str, + agent_kind: str, + ) -> VeStackManagedTool: + """Create and wait for one independent Studio-owned Tool.""" + raise NotImplementedError # pragma: no cover - Protocol declaration + + async def delete_managed_tool(self, tool: VeStackManagedTool) -> None: + """Delete one Studio-created per-agent Tool.""" + raise NotImplementedError # pragma: no cover - Protocol declaration + async def list_sessions( self, tool_id: str, username: str | None = None ) -> list[SandboxCloudSession]: @@ -1560,11 +1656,14 @@ def __init__( tool_id: str | None = None, snapshot_tool_id: str | None = None, agent_kind: str = _SANDBOX_CODEX_AGENT_KIND, + managed_tool_spec: VeStackManagedToolSpec | None = None, ) -> None: self._gateway = gateway self._configured_tool_id = (tool_id or "").strip() self._configured_snapshot_tool_id = (snapshot_tool_id or "").strip() self._agent_kind = agent_kind + self._managed_tool_spec = managed_tool_spec + self._managed_tools_by_session: dict[str, VeStackManagedTool] = {} self._sessions: dict[tuple[str, str], SandboxConversation] = {} self._registry_lock = asyncio.Lock() self._sessions_starting = 0 @@ -1572,12 +1671,34 @@ def __init__( def capabilities(self) -> dict[str, object]: """Report whether the dedicated Codex Tool is configured.""" tools = self._tools() - enabled = bool(tools.configured) + enabled = self._managed_tool_spec is not None or bool(tools.configured) + if self._managed_tool_spec is not None: + return { + "enabled": enabled, + "reason": "" if enabled else "管理员未配置", + "persistentEnabled": True, + "persistentReason": "", + "persistentRequired": True, + "storageMode": "disk", + "diskGbDefault": self._managed_tool_spec.disk_gb, + "diskGbMin": STUDIO_SANDBOX_DISK_GB_MIN, + "diskGbMax": STUDIO_SANDBOX_DISK_GB_MAX, + "endpointExportEnabled": _sandbox_endpoint_export_enabled(), + } + persistent_enabled = bool(tools.persistent) return { "enabled": enabled, "reason": "" if enabled else "管理员未配置", - "persistentEnabled": bool(tools.persistent), - "persistentReason": "" if tools.persistent else "管理员未配置快照版 Tool", + "persistentEnabled": persistent_enabled, + "persistentReason": ( + "" + if persistent_enabled + else ( + "当前环境仅支持独立 Tool" + if self._managed_tool_spec is not None + else "管理员未配置快照版 Tool" + ) + ), "endpointExportEnabled": _sandbox_endpoint_export_enabled(), } @@ -1604,8 +1725,38 @@ async def get_tool(self, *, persistent: bool = False) -> Any: """Read the configured transient or snapshot Sandbox Tool.""" return await self._gateway.get_tool(self._tool_id(persistent=persistent)) + async def _managed_tool_for_session( + self, session_id: str + ) -> VeStackManagedTool | None: + cached = self._managed_tools_by_session.get(session_id) + if cached is not None: + return cached + for tool in await self._gateway.list_managed_tools(self._agent_kind): + try: + sessions = await self._gateway.list_sessions(tool.tool_id) + except SandboxError: + continue + for session in sessions: + self._managed_tools_by_session[session.instance_id] = tool + if session.instance_id == session_id: + return tool + return None + async def _cloud_session(self, session_id: str) -> SandboxCloudSession: """Find a Session across the configured transient and snapshot Tools.""" + if self._managed_tool_spec is not None: + tool = await self._managed_tool_for_session(session_id) + if tool is None: + raise SandboxSessionNotFoundError("AgentKit Session 不存在或已过期。") + cloud = await self._gateway.get_session(tool.tool_id, session_id) + return replace( + cloud, + display_name=cloud.display_name or tool.display_name, + created_by=cloud.created_by or tool.created_by, + creator_name=cloud.creator_name or tool.creator_name, + agent_kind=cloud.agent_kind or tool.agent_kind or self._agent_kind, + persistent=True, + ) tools = self._tools() if not tools.configured: self._tool_id() @@ -1628,6 +1779,30 @@ async def list_sessions( self, owner_id: str, *, is_admin: bool = False ) -> list[SandboxCloudSession]: """List the configured account's Sessions without exposing Endpoints.""" + if self._managed_tool_spec is not None: + managed_tools = await self._gateway.list_managed_tools( + self._agent_kind, + None if is_admin else owner_id, + ) + sessions: dict[str, SandboxCloudSession] = {} + for tool in managed_tools: + for session in await self._gateway.list_sessions(tool.tool_id): + self._managed_tools_by_session[session.instance_id] = tool + sessions[session.instance_id] = replace( + session, + display_name=session.display_name or tool.display_name, + created_by=session.created_by or tool.created_by, + creator_name=session.creator_name or tool.creator_name, + agent_kind=( + session.agent_kind or tool.agent_kind or self._agent_kind + ), + persistent=True, + ) + return sorted( + sessions.values(), + key=lambda session: session.created_at, + reverse=True, + ) tools = self._tools() if not tools.configured: self._tool_id() @@ -1656,6 +1831,8 @@ async def list_snapshots( self, owner_id: str, *, is_admin: bool = False ) -> list[SandboxCloudSnapshot]: del owner_id + if self._managed_tool_spec is not None: + return [] tools = self._tools() if not is_admin or not tools.persistent: return [] @@ -1733,6 +1910,7 @@ async def create( creator_name: str = "", persistent: object = True, envs: Mapping[str, str] | None = None, + disk_gb: object = None, ) -> SandboxCloudSession: """Create a cloud Session without opening a conversation connection.""" if not isinstance(display_name, str): @@ -1764,7 +1942,6 @@ async def create( session_envs[key] = normalized if not session_envs: session_envs = None - tool_id = self._tool_id(persistent=persistent) await self.cleanup_expired() async with self._registry_lock: if len(self._sessions) + self._sessions_starting >= ( @@ -1773,17 +1950,51 @@ async def create( raise SandboxCapacityError("Sandbox 创建或连接数已达上限,请稍后重试。") self._sessions_starting += 1 try: - created = await self._gateway.create_session( - tool_id, - display_name, - owner_id, - creator_name, - self._agent_kind, - **({"envs": session_envs} if session_envs else {}), - ) - authoritative = await self._gateway.get_session( - tool_id, created.instance_id - ) + managed_tool: VeStackManagedTool | None = None + if self._managed_tool_spec is not None: + managed_spec = replace( + self._managed_tool_spec, + disk_gb=_managed_tool_disk_gb( + disk_gb, + self._managed_tool_spec.disk_gb, + ), + ) + managed_tool = await self._gateway.create_managed_tool( + managed_spec, + display_name=display_name, + owner_id=owner_id, + creator_name=creator_name, + agent_kind=self._agent_kind, + ) + tool_id = managed_tool.tool_id + else: + tool_id = self._tool_id(persistent=persistent) + try: + created = await self._gateway.create_session( + tool_id, + display_name, + owner_id, + creator_name, + self._agent_kind, + **({"envs": session_envs} if session_envs else {}), + ) + authoritative = await self._gateway.get_session( + tool_id, created.instance_id + ) + except Exception: + if managed_tool is not None: + await self._gateway.delete_managed_tool(managed_tool) + raise + if managed_tool is not None: + self._managed_tools_by_session[created.instance_id] = managed_tool + return replace( + authoritative, + display_name=authoritative.display_name or display_name, + created_by=authoritative.created_by or owner_id, + creator_name=authoritative.creator_name or creator_name, + agent_kind=authoritative.agent_kind or self._agent_kind, + persistent=True, + ) return _session_for_tools( replace( authoritative, @@ -2429,6 +2640,11 @@ async def delete( is_admin: bool = False, ) -> None: """Delete a cloud Session and close its local bridge when connected.""" + managed_tool = ( + await self._managed_tool_for_session(session_id) + if self._managed_tool_spec is not None + else None + ) key = (owner_id, session_id) if not is_admin and any( candidate_id == session_id and candidate_owner != owner_id @@ -2454,6 +2670,9 @@ async def delete( async with candidate.lock: await candidate.codex.close() await self._gateway.delete_session(cloud) + if managed_tool is not None: + self._managed_tools_by_session.pop(session_id, None) + await self._gateway.delete_managed_tool(managed_tool) async def cleanup_expired(self) -> None: """Drop local connections that exceeded their remote TTL window.""" @@ -2500,25 +2719,96 @@ def __init__( display_name_prefix: str = "", allow_admin_cross_owner: bool = True, terminal_initial_command: str = "", + surface_start_command: str = "", + surface_ready_path: str = "", unconfigured_message: str = "", + managed_tool_spec: VeStackManagedToolSpec | None = None, ) -> None: if kind not in _SANDBOX_AGENT_TOOL_ENVS: raise ValueError(f"Unsupported Studio sandbox agent kind: {kind}") self._gateway = gateway self.kind = kind surface = (surface_path or f"/{kind}/").strip() - self.surface_path = f"/{surface.strip('/')}/" + normalized_surface = f"/{surface.strip('/')}" + self.surface_path = ( + normalized_surface + if normalized_surface.lower().endswith((".html", ".htm")) + else f"{normalized_surface}/" + ) self._filter_agent_kind = filter_agent_kind self._display_name_prefix = display_name_prefix.strip() self.allow_admin_cross_owner = allow_admin_cross_owner self._terminal_initial_command = terminal_initial_command.strip() + self._surface_start_command = surface_start_command.strip() + self._surface_ready_path = surface_ready_path.strip() self._unconfigured_message = unconfigured_message.strip() + self._managed_tool_spec = managed_tool_spec self._configured_tool_id = (tool_id or "").strip() self._configured_snapshot_tool_id = (snapshot_tool_id or "").strip() self._workspaces: dict[ tuple[str, str], tuple[SandboxCloudSession, str, float] ] = {} self._created_session_ids: set[str] = set() + self._managed_tools_by_session: dict[str, VeStackManagedTool] = {} + self._surface_start_locks: dict[str, asyncio.Lock] = {} + + async def _surface_is_ready(self, endpoint: str) -> bool: + if not self._surface_ready_path: + return True + try: + async with httpx.AsyncClient( + timeout=5, + follow_redirects=False, + trust_env=False, + ) as client: + response = await client.get( + sandbox_service_url(endpoint, self._surface_ready_path), + headers={"accept": "text/html"}, + ) + except (httpx.HTTPError, TypeError, ValueError): + return False + return 200 <= response.status_code < 300 + + async def _ensure_surface_ready(self, cloud: SandboxCloudSession) -> None: + if not self._surface_start_command or not self._surface_ready_path: + return + lock = self._surface_start_locks.setdefault( + cloud.instance_id, + asyncio.Lock(), + ) + async with lock: + if await self._surface_is_ready(cloud.endpoint): + return + try: + async with httpx.AsyncClient( + timeout=15, + follow_redirects=False, + trust_env=False, + ) as client: + response = await client.post( + sandbox_service_url(cloud.endpoint, "/v1/shell/exec"), + headers={"content-type": "application/json"}, + json={ + "id": "", + "exec_dir": "/home/gem/.hermes", + "command": self._surface_start_command, + "timeout": 5, + "hard_timeout": 15, + "strict": True, + }, + ) + except (httpx.HTTPError, TypeError, ValueError) as error: + raise SandboxInvocationError("无法启动 Hermes Dashboard。") from error + if response.status_code < 200 or response.status_code >= 300: + raise SandboxInvocationError( + f"Hermes Dashboard 启动服务返回 HTTP {response.status_code}。" + ) + for attempt in range(_AGENT_SURFACE_READY_ATTEMPTS): + if await self._surface_is_ready(cloud.endpoint): + return + if attempt + 1 < _AGENT_SURFACE_READY_ATTEMPTS: + await asyncio.sleep(_AGENT_SURFACE_READY_INTERVAL_SECONDS) + raise SandboxInvocationError("Hermes Dashboard 启动超时,请稍后重试。") def _tools(self) -> SandboxToolPair: transient = self._configured_tool_id @@ -2548,15 +2838,62 @@ def _tool_id(self, *, persistent: bool = False, required: bool = True) -> str: def capabilities(self) -> dict[str, object]: tools = self._tools() - enabled = bool(tools.configured) + enabled = self._managed_tool_spec is not None or bool(tools.configured) + if self._managed_tool_spec is not None: + return { + "enabled": enabled, + "reason": "" + if enabled + else (self._unconfigured_message or "管理员未配置"), + "persistentEnabled": True, + "persistentReason": "", + "persistentRequired": True, + "storageMode": "disk", + "diskGbDefault": self._managed_tool_spec.disk_gb, + "diskGbMin": STUDIO_SANDBOX_DISK_GB_MIN, + "diskGbMax": STUDIO_SANDBOX_DISK_GB_MAX, + } + persistent_enabled = bool(tools.persistent) return { "enabled": enabled, "reason": "" if enabled else (self._unconfigured_message or "管理员未配置"), - "persistentEnabled": bool(tools.persistent), - "persistentReason": "" if tools.persistent else "管理员未配置快照版 Tool", + "persistentEnabled": persistent_enabled, + "persistentReason": ( + "" if persistent_enabled else "当前环境仅支持独立 Tool" + ), } + async def _managed_tool_for_session( + self, session_id: str + ) -> VeStackManagedTool | None: + cached = self._managed_tools_by_session.get(session_id) + if cached is not None: + return cached + for tool in await self._gateway.list_managed_tools(self.kind): + try: + sessions = await self._gateway.list_sessions(tool.tool_id) + except SandboxError: + continue + for session in sessions: + self._managed_tools_by_session[session.instance_id] = tool + if session.instance_id == session_id: + return tool + return None + async def _cloud_session(self, session_id: str) -> SandboxCloudSession: + if self._managed_tool_spec is not None: + tool = await self._managed_tool_for_session(session_id) + if tool is None: + raise SandboxSessionNotFoundError("AgentKit Session 不存在或已过期。") + cloud = await self._gateway.get_session(tool.tool_id, session_id) + return replace( + cloud, + display_name=cloud.display_name or tool.display_name, + created_by=cloud.created_by or tool.created_by, + creator_name=cloud.creator_name or tool.creator_name, + agent_kind=cloud.agent_kind or tool.agent_kind or self.kind, + persistent=True, + ) tools = self._tools() if not tools.configured: self._tool_id() @@ -2580,6 +2917,49 @@ async def _cloud_session(self, session_id: str) -> SandboxCloudSession: async def list_sessions( self, owner_id: str, *, is_admin: bool = False ) -> list[SandboxCloudSession]: + if self._managed_tool_spec is not None: + managed_tools = await self._gateway.list_managed_tools( + self.kind, + None if is_admin else owner_id, + ) + sessions: dict[str, SandboxCloudSession] = {} + for tool in managed_tools: + if tool.status.lower() in {"deleting", "deleted"}: + self._managed_tools_by_session = { + session_id: cached_tool + for session_id, cached_tool in self._managed_tools_by_session.items() + if cached_tool.tool_id != tool.tool_id + } + continue + try: + found = await self._gateway.list_sessions(tool.tool_id) + except SandboxError as error: + # Tool deletion is asynchronous. ListTools can briefly return + # a stale Ready item after its Session data plane has already + # disappeared, where ListSessions reports InternalError. One + # retiring Tool must not make every managed agent unavailable. + logger.warning( + "Skipping %s Tool %s while listing Sessions: %s", + self.kind, + tool.tool_id, + type(error).__name__, + ) + continue + for session in found: + self._managed_tools_by_session[session.instance_id] = tool + sessions[session.instance_id] = replace( + session, + display_name=session.display_name or tool.display_name, + created_by=session.created_by or tool.created_by, + creator_name=session.creator_name or tool.creator_name, + agent_kind=session.agent_kind or tool.agent_kind or self.kind, + persistent=True, + ) + return sorted( + sessions.values(), + key=lambda session: session.created_at, + reverse=True, + ) tools = self._tools() if not tools.configured: self._tool_id() @@ -2610,6 +2990,8 @@ async def list_snapshots( self, owner_id: str, *, is_admin: bool = False ) -> list[SandboxCloudSnapshot]: del owner_id + if self._managed_tool_spec is not None: + return [] tools = self._tools() if not is_admin or not tools.persistent: return [] @@ -2686,6 +3068,7 @@ async def create( display_name: object = "", creator_name: str = "", persistent: object = True, + disk_gb: object = None, ) -> SandboxCloudSession: if not isinstance(display_name, str): raise SandboxValidationError("智能体名称必须是文本。") @@ -2704,6 +3087,44 @@ async def create( ) if not isinstance(persistent, bool): raise SandboxValidationError("persistent 必须是布尔值。") + if self._managed_tool_spec is not None: + managed_spec = replace( + self._managed_tool_spec, + disk_gb=_managed_tool_disk_gb( + disk_gb, + self._managed_tool_spec.disk_gb, + ), + ) + tool = await self._gateway.create_managed_tool( + managed_spec, + display_name=display_name, + owner_id=owner_id, + creator_name=creator_name, + agent_kind=self.kind, + ) + try: + created = await self._gateway.create_session( + tool.tool_id, + display_name, + owner_id, + creator_name, + self.kind, + ) + authoritative = await self._gateway.get_session( + tool.tool_id, created.instance_id + ) + except Exception: + await self._gateway.delete_managed_tool(tool) + raise + self._managed_tools_by_session[created.instance_id] = tool + return replace( + authoritative, + display_name=authoritative.display_name or display_name, + created_by=authoritative.created_by or owner_id, + creator_name=authoritative.creator_name or creator_name, + agent_kind=authoritative.agent_kind or self.kind, + persistent=True, + ) tool_id = self._tool_id(persistent=persistent) created = await self._gateway.create_session( tool_id, @@ -2735,7 +3156,8 @@ async def open( raise SandboxSessionUnavailableError( f"AgentKit Session 尚未就绪,当前状态:{status}。" ) - token = secrets.token_urlsafe(32) + await self._ensure_surface_ready(cloud) + token = _agent_surface_capability(self.kind, session_id) self._workspaces[(owner_id, session_id)] = ( cloud, token, @@ -2751,6 +3173,11 @@ async def delete( is_admin: bool = False, ) -> None: """Delete one managed cloud Session and revoke its local workspace.""" + managed_tool = ( + await self._managed_tool_for_session(session_id) + if self._managed_tool_spec is not None + else None + ) if not is_admin and any( candidate_id == session_id and candidate_owner != owner_id for candidate_owner, candidate_id in self._workspaces @@ -2770,14 +3197,34 @@ async def delete( } self._created_session_ids.discard(session_id) await self._gateway.delete_session(cloud) + if managed_tool is not None: + self._managed_tools_by_session.pop(session_id, None) + await self._gateway.delete_managed_tool(managed_tool) async def launch_terminal( self, session_id: str, owner_id: str, + *, + is_admin: bool = False, ) -> tuple[str, str, str]: - """Create a shell for an opened branded Session.""" - cloud, token, _expires_at = self._workspace(session_id, owner_id) + """Create a shell, restoring replica-local state when necessary.""" + try: + cloud, token, _expires_at = self._workspace(session_id, owner_id) + except SandboxSessionNotFoundError: + cloud = await self._cloud_session(session_id) + _require_session_access(cloud, owner_id, is_admin=is_admin) + if cloud.status.lower() != "ready" or not cloud.endpoint: + status = cloud.status or "Unknown" + raise SandboxSessionUnavailableError( + f"AgentKit Session 尚未就绪,当前状态:{status}。" + ) + token = _agent_surface_capability(self.kind, session_id) + self._workspaces[(owner_id, session_id)] = ( + cloud, + token, + time.monotonic() + STUDIO_SANDBOX_TTL_SECONDS, + ) try: if self._terminal_initial_command: url = terminal_initial_command_url( @@ -2813,6 +3260,27 @@ def resolve_proxy_target( raise PermissionError("invalid managed agent proxy capability") raise KeyError(session_id) + async def resolve_surface_proxy_target( + self, + session_id: str, + token: str, + ) -> SandboxProxyTarget: + """Resolve a WebUI capability on any Studio replica.""" + try: + return self.resolve_proxy_target(session_id, token) + except (KeyError, PermissionError): + # A different replica, or a later open on this replica, may have a + # different still-valid capability cached for the same Session. + # Fall back to the shared HMAC signature instead of treating the + # replica-local cache as authoritative. + pass + if not _valid_agent_surface_capability(token, self.kind, session_id): + raise PermissionError("invalid managed agent surface capability") + cloud = await self._cloud_session(session_id) + if cloud.status.lower() != "ready" or not cloud.endpoint: + raise KeyError(session_id) + return SandboxProxyTarget(endpoint=cloud.endpoint) + def _workspace( self, session_id: str, @@ -2993,6 +3461,7 @@ async def _create_sandbox_agent_session( data.get("displayName", ""), creator_resolver(request) if creator_resolver else owner_id, data.get("persistent", True), + data.get("diskGb"), ) except SandboxError as error: raise _http_error(error) from error @@ -3079,9 +3548,11 @@ async def _open_sandbox_agent_terminal( request: Request, ) -> JSONResponse: try: - url, shell_session_id, token = await _service(kind).launch_terminal( + service = _service(kind) + url, shell_session_id, token = await service.launch_terminal( session_id, owner_resolver(request), + is_admin=_is_admin(service, request), ) except SandboxError as error: raise _http_error(error) from error @@ -3104,12 +3575,12 @@ async def _open_sandbox_agent_terminal( ) return response - def _surface_target( + async def _surface_target( kind: str, session_id: str, token: str, ) -> SandboxProxyTarget: - return _service(kind).resolve_proxy_target(session_id, token) + return await _service(kind).resolve_surface_proxy_target(session_id, token) mount_agent_surface_proxy_routes(app, _surface_target) @@ -3533,6 +4004,7 @@ async def _start_sandbox_session(request: Request) -> dict[str, object]: data.get("displayName", ""), creator_resolver(request) if creator_resolver else owner_id, data.get("persistent", True), + disk_gb=data.get("diskGb"), ) except SandboxError as error: raise _http_error(error) from error diff --git a/veadk/cli/frontend_sandbox_managed_tool_vestack.py b/veadk/cli/frontend_sandbox_managed_tool_vestack.py new file mode 100644 index 000000000..3ef5b9454 --- /dev/null +++ b/veadk/cli/frontend_sandbox_managed_tool_vestack.py @@ -0,0 +1,298 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +"""VeStack per-agent Tool provisioning for Studio Sandbox sessions.""" + +from __future__ import annotations + +import asyncio +import secrets +import uuid +from dataclasses import dataclass, field, replace +from typing import Any + +from veadk.cli.agentkit_sandbox_region import is_agentkit_resource_not_found +from veadk.cli.agentkit_session_metadata import session_display_name_metadata_value +from veadk.cli.frontend_sandbox import ( + AgentkitSandboxGateway, + SandboxError, + SandboxProvisioningError, + _safe_error_message, +) + +_TAG_AGENT_KIND = "veadk_agent_kind" +_TAG_CREATED_BY = "veadk_creator_name" +_TAG_DISPLAY_NAME = "veadk_display_name" +_TAG_MANAGED_BY = "veadk_managed_by" +_TAG_OWNER = "veadk_owner" +_TAG_MANAGED_BY_VALUE = "studio" +_READY_ATTEMPTS = 30 +_READY_INTERVAL_SECONDS = 2 + + +@dataclass(frozen=True) +class VeStackManagedTool: + """One Studio-owned VeStack AgentKit Tool for one personal agent.""" + + tool_id: str + name: str + region: str = "" + status: str = "Unknown" + created_at: str = "" + display_name: str = "" + created_by: str = "" + creator_name: str = "" + agent_kind: str = "" + + +@dataclass(frozen=True) +class VeStackManagedToolSpec: + """VeStack deployment configuration for creating independent Tools.""" + + tool_type: str + role_name: str + model_agent_name: str = "" + model_agent_api_base: str = "" + model_agent_api_key: str = field(default="", repr=False) + model_agent_model_id: str = "" + project_name: str = "default" + cpu_milli: int = 2000 + memory_mb: int = 4096 + port: int = 8080 + disk_gb: int = 10 + + +def _tool_tag(value: Any, key: str) -> str: + for tag in getattr(value, "tags", None) or (): + tag_key = ( + tag.get("Key") or tag.get("key") + if isinstance(tag, dict) + else getattr(tag, "key", "") + ) + if tag_key != key: + continue + tag_value = ( + tag.get("Value") or tag.get("value") + if isinstance(tag, dict) + else getattr(tag, "value", "") + ) + return str(tag_value or "").strip() + return "" + + +class VeStackAgentkitSandboxGateway(AgentkitSandboxGateway): + """AgentKit gateway extended with VeStack per-agent Tool lifecycle APIs.""" + + @staticmethod + def _managed_tool(value: Any, *, region: str = "") -> VeStackManagedTool: + return VeStackManagedTool( + tool_id=str(getattr(value, "tool_id", "") or "").strip(), + name=str(getattr(value, "name", "") or "").strip(), + region=region, + status=str(getattr(value, "status", "") or "Unknown").strip(), + created_at=str(getattr(value, "created_at", "") or "").strip(), + display_name=_tool_tag(value, _TAG_DISPLAY_NAME), + created_by=_tool_tag(value, _TAG_OWNER), + creator_name=_tool_tag(value, _TAG_CREATED_BY), + agent_kind=_tool_tag(value, _TAG_AGENT_KIND), + ) + + async def list_managed_tools( + self, agent_kind: str, owner_id: str | None = None + ) -> list[VeStackManagedTool]: + from agentkit.sdk.tools import types as tools_types + + regions = self._region_candidates or ("",) + for index, region in enumerate(regions): + tools: dict[str, VeStackManagedTool] = {} + next_token: str | None = None + try: + for _page in range(100): + tag_filters = [ + tools_types.TagFiltersItemForListTools( + Key=_TAG_MANAGED_BY, + Values=[_TAG_MANAGED_BY_VALUE], + ), + tools_types.TagFiltersItemForListTools( + Key=_TAG_AGENT_KIND, + Values=[agent_kind], + ), + ] + if owner_id is not None: + tag_filters.append( + tools_types.TagFiltersItemForListTools( + Key=_TAG_OWNER, + Values=[owner_id], + ) + ) + response = await self._call( + "list_tools", + tools_types.ListToolsRequest( + MaxResults=100, + NextToken=next_token, + TagFilters=tag_filters, + ), + region=region, + ) + for value in response.tools or []: + tool = self._managed_tool(value, region=region) + if tool.tool_id: + tools[tool.tool_id] = tool + next_token = str(response.next_token or "").strip() or None + if next_token is None: + return sorted( + tools.values(), + key=lambda item: item.created_at, + reverse=True, + ) + raise SandboxProvisioningError("AgentKit ListTools 分页超过安全上限。") + except SandboxError: + raise + except Exception as error: + if is_agentkit_resource_not_found(error) and index + 1 < len(regions): + continue + raise SandboxProvisioningError( + f"读取 AgentKit Tool 列表失败:{_safe_error_message(error)}" + ) from error + return [] + + async def create_managed_tool( + self, + spec: VeStackManagedToolSpec, + *, + display_name: str, + owner_id: str, + creator_name: str, + agent_kind: str, + ) -> VeStackManagedTool: + from agentkit.sdk.tools import types as tools_types + + display_name = session_display_name_metadata_value(display_name) + creator_name = session_display_name_metadata_value(creator_name) + owner_id = session_display_name_metadata_value(owner_id) + suffix = secrets.token_hex(4) + request_data: dict[str, Any] = { + "Name": f"VeADK-{agent_kind.title()}-{suffix}", + "ToolType": spec.tool_type, + "ProjectName": spec.project_name, + "RoleName": spec.role_name, + "Description": "Created by VeADK Studio", + "EnableSecurity": True, + "EnableSnapshot": False, + "CpuMilli": spec.cpu_milli, + "MemoryMb": spec.memory_mb, + "Port": spec.port, + "ClientToken": f"veadk-studio-{uuid.uuid4().hex}", + "AuthorizerConfiguration": tools_types.AuthorizerForCreateTool( + KeyAuth=tools_types.AuthorizerKeyAuthForCreateTool( + ApiKeyLocation="Header", + ApiKeyName=f"apikey_{suffix}", + ) + ), + # VeStack requires DiskGb in Envs because the generated SDK does not + # expose it as a top-level CreateTool field. + "Envs": [ + tools_types.EnvsItemForCreateTool( + Key="DiskGb", + Value=str(spec.disk_gb), + ), + *[ + tools_types.EnvsItemForCreateTool(Key=key, Value=value) + for key, value in ( + ("MODEL_AGENT_API_BASE", spec.model_agent_api_base), + ("MODEL_AGENT_API_KEY", spec.model_agent_api_key), + ("MODEL_AGENT_MODEL_ID", spec.model_agent_model_id), + ) + if value + ], + ], + "Tags": [ + tools_types.TagsItemForCreateTool( + Key=_TAG_MANAGED_BY, + Type="String", + Value=_TAG_MANAGED_BY_VALUE, + ), + tools_types.TagsItemForCreateTool( + Key=_TAG_AGENT_KIND, + Type="String", + Value=agent_kind, + ), + tools_types.TagsItemForCreateTool( + Key=_TAG_OWNER, + Type="String", + Value=owner_id, + ), + tools_types.TagsItemForCreateTool( + Key=_TAG_CREATED_BY, + Type="String", + Value=creator_name, + ), + tools_types.TagsItemForCreateTool( + Key=_TAG_DISPLAY_NAME, + Type="String", + Value=display_name, + ), + ], + } + if spec.model_agent_name: + request_data["ModelAgentName"] = spec.model_agent_name + request = tools_types.CreateToolRequest(**request_data) + regions = self._region_candidates or ("",) + for index, region in enumerate(regions): + try: + created = await self._call("create_tool", request, region=region) + tool_id = str(getattr(created, "tool_id", "") or "").strip() + if not tool_id: + raise SandboxProvisioningError( + "AgentKit 创建 Tool 响应缺少 ToolId。" + ) + for attempt in range(_READY_ATTEMPTS): + latest = await self._call( + "get_tool", + tools_types.GetToolRequest(ToolId=tool_id), + region=region, + ) + status = str(getattr(latest, "status", "") or "").lower() + if status == "ready": + tool = self._managed_tool(latest, region=region) + return replace( + tool, + display_name=tool.display_name or display_name, + created_by=tool.created_by or owner_id, + creator_name=tool.creator_name or creator_name, + agent_kind=tool.agent_kind or agent_kind, + ) + if status in {"failed", "error", "deleted"}: + raise SandboxProvisioningError( + f"AgentKit Tool 创建失败,当前状态:{status}。" + ) + if attempt + 1 < _READY_ATTEMPTS: + await asyncio.sleep(_READY_INTERVAL_SECONDS) + raise SandboxProvisioningError("AgentKit Tool 创建超时。") + except SandboxError: + raise + except Exception as error: + if is_agentkit_resource_not_found(error) and index + 1 < len(regions): + continue + raise SandboxProvisioningError( + f"创建 AgentKit Tool 失败:{_safe_error_message(error)}" + ) from error + raise SandboxProvisioningError("无法在支持的地域创建 AgentKit Tool。") + + async def delete_managed_tool(self, tool: VeStackManagedTool) -> None: + from agentkit.sdk.tools import types as tools_types + + try: + await self._call( + "delete_tool", + tools_types.DeleteToolRequest(ToolId=tool.tool_id), + region=tool.region, + ) + except Exception as error: + if is_agentkit_resource_not_found(error): + return + raise SandboxProvisioningError( + f"删除 AgentKit Tool 失败:{_safe_error_message(error)}" + ) from error diff --git a/veadk/cli/vestack_studio_deploy.py b/veadk/cli/vestack_studio_deploy.py new file mode 100644 index 000000000..0516e6104 --- /dev/null +++ b/veadk/cli/vestack_studio_deploy.py @@ -0,0 +1,271 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +"""Deploy Studio as a VeStack VeFaaS image function behind APIG.""" + +from __future__ import annotations + +from dataclasses import dataclass +import os +import time + +import volcenginesdkvefaas + +import veadk.config +from veadk.integrations.ve_apig.ve_apig import APIGateway +from veadk.integrations.ve_faas.ve_faas import VeFaaS + + +@dataclass(frozen=True) +class VeStackStudioDeployment: + """Cloud resources created for one Studio image deployment.""" + + endpoint: str + function_id: str + gateway_id: str + service_id: str + upstream_id: str + route_id: str + + +_LONG_LIVED_CREDENTIAL_ENV_KEYS = { + "ACCESS_KEY_ID", + "SECRET_ACCESS_KEY", + "VOLCENGINE_ACCESS_KEY", + "VOLCENGINE_ACCESS_KEY_ID", + "VOLCENGINE_SECRET_ACCESS_KEY", + "VOLCENGINE_SECRET_KEY", +} + + +def _safe_function_environment(environment: dict[str, str]) -> dict[str, str]: + """Strip deployer credentials before any Function create/update request.""" + return { + key: value + for key, value in environment.items() + if key.upper() not in _LONG_LIVED_CREDENTIAL_ENV_KEYS + } + + +def _wait_for_function_release( + service: VeFaaS, + function_id: str, + *, + attempts: int = 120, + interval_seconds: float = 5, +) -> None: + for _ in range(attempts): + response = service.client.get_release_status( + volcenginesdkvefaas.GetReleaseStatusRequest(function_id=function_id) + ) + state = str(getattr(response, "status", "") or "").lower() + if state == "done" or "succ" in state: + return + if "fail" in state or "error" in state: + raise RuntimeError(f"VeFaaS function release failed: {state}") + time.sleep(interval_seconds) + raise TimeoutError("VeFaaS function release did not complete before timeout") + + +def deploy_vestack_studio_image( + *, + access_key: str, + secret_key: str, + session_token: str, + region: str, + project: str, + application_name: str, + image: str, + role_trn: str, + public_domain: str, + gateway_name: str = "", + gateway_service_name: str = "", + gateway_upstream_name: str = "", + apig_cluster_id: str = "", + apig_namespace: str = "", + apig_cluster_name: str = "aio", + environment: dict[str, str] | None = None, +) -> VeStackStudioDeployment: + """Create and release a VeFaaS Function, then expose it through APIG. + + VeStack does not provide the public-cloud VeFaaS Application/BFF API. This + function performs the Application's essential orchestration explicitly. + Long-lived deployer credentials are used only to sign control-plane calls; + they are never copied into the Function environment. + """ + if not image.strip(): + raise ValueError("A VeStack Studio image is required") + if not public_domain.strip(): + raise ValueError("A dedicated Studio public domain is required") + + function_name = f"{application_name}-fn" + gateway_name = gateway_name or f"{application_name}-gateway" + gateway_service_name = gateway_service_name or f"{application_name}-service" + gateway_upstream_name = gateway_upstream_name or f"{application_name}-upstream" + + requested_environment = _safe_function_environment(environment or {}) + veadk.config.veadk_environments.clear() + veadk.config.veadk_environments.update(requested_environment) + + service = VeFaaS( + access_key=access_key, + secret_key=secret_key, + session_token=session_token, + region=region, + project_name=project, + provider="volcengine", + ) + # IAM_ROLE is intentionally read by the create request rather than included + # in ``veadk_environments`` so no long-lived credential reaches the pod. + previous_role = os.environ.get("IAM_ROLE") + os.environ["IAM_ROLE"] = role_trn + try: + functions = list( + getattr( + service.client.list_functions( + volcenginesdkvefaas.ListFunctionsRequest( + page_number=1, page_size=100 + ) + ), + "items", + None, + ) + or [] + ) + existing_function = next( + (item for item in functions if getattr(item, "name", "") == function_name), + None, + ) + if existing_function is None: + _, function_id = service._create_image_function(function_name, image) + else: + function_id = str(getattr(existing_function, "id")) + current_function = service.client.get_function( + volcenginesdkvefaas.GetFunctionRequest(id=function_id) + ) + deployed_environment = _safe_function_environment( + { + str(item.key): str(item.value) + for item in (getattr(current_function, "envs", None) or []) + } + ) + deployed_environment.update(requested_environment) + veadk.config.veadk_environments.clear() + veadk.config.veadk_environments.update(deployed_environment) + service.client.update_function( + volcenginesdkvefaas.UpdateFunctionRequest( + id=function_id, + command="bash ./run.sh", + source_type="image", + source=image, + envs=[ + volcenginesdkvefaas.EnvForUpdateFunctionInput( + key=key, value=value + ) + for key, value in sorted(deployed_environment.items()) + ], + memory_mb=4096, + role=role_trn, + project_name=project, + ) + ) + finally: + if previous_role is None: + os.environ.pop("IAM_ROLE", None) + else: + os.environ["IAM_ROLE"] = previous_role + + service.client.release( + volcenginesdkvefaas.ReleaseRequest( + function_id=function_id, + revision_number=0, + ) + ) + _wait_for_function_release(service, function_id) + + apig = APIGateway( + access_key, + secret_key, + region, + session_token=session_token, + provider="volcengine", + ) + gateways = list(getattr(apig.list_gateways(), "items", None) or []) + gateway = next( + (item for item in gateways if getattr(item, "name", "") == gateway_name), + None, + ) + gateway_id = ( + str(getattr(gateway, "id", "")) + if gateway is not None + else apig.create_serverless_gateway( + gateway_name, + vestack_cluster_id=apig_cluster_id, + vestack_namespace=apig_namespace, + vestack_cluster_name=apig_cluster_name, + ) + ) + gateway_service = apig.find_gateway_service(gateway_id, gateway_service_name) + service_id = ( + str(getattr(gateway_service, "id")) + if gateway_service is not None + else apig.create_gateway_service( + gateway_id, + gateway_service_name, + custom_domain=public_domain, + vestack=bool(apig_cluster_id), + ) + ) + upstream = apig.find_upstream(gateway_id, gateway_upstream_name) + upstream_id = ( + str(getattr(upstream, "id")) + if upstream is not None + else apig.create_vefaas_upstream( + function_id, + gateway_id, + gateway_upstream_name, + ) + ) + route_name = f"{application_name}-root" + route = apig.find_route(service_id, route_name) + route_id = ( + str(getattr(route, "id")) + if route is not None + else apig.create_gateway_service_routes( + service_id, + upstream_id, + route_name, + { + "match_content": "/", + "match_type": "Prefix", + "match_method": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS", + ], + }, + ) + ) + if apig_cluster_id: + _, public_port = apig.get_gateway_external_http_address(gateway_id) + endpoint = f"http://{public_domain.strip().rstrip('/')}:{public_port}" + else: + endpoint = f"http://{public_domain.strip().rstrip('/')}" + service.update_function_envs_and_release( + function_id, + {"OAUTH2_REDIRECT_URI": f"{endpoint}/oauth2/callback"}, + ) + return VeStackStudioDeployment( + endpoint=endpoint, + function_id=function_id, + gateway_id=gateway_id, + service_id=service_id, + upstream_id=upstream_id, + route_id=route_id, + ) diff --git a/veadk/integrations/ve_apig/ve_apig.py b/veadk/integrations/ve_apig/ve_apig.py index e42ee80a2..b7845f878 100644 --- a/veadk/integrations/ve_apig/ve_apig.py +++ b/veadk/integrations/ve_apig/ve_apig.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import os import time @@ -23,6 +24,7 @@ DEFAULT_CLOUD_PROVIDER, CloudProvider, apig_openapi_host, + configure_openapi_tls, ) from veadk.utils.volcengine_sign import ve_request @@ -51,7 +53,9 @@ def __init__( configuration.sk = self.sk configuration.session_token = self.session_token configuration.region = region - configuration.host = f"https://{self.openapi_host}" + scheme = os.getenv("APIG_OPENAPI_SCHEME", "https").strip() or "https" + configuration.host = f"{scheme}://{self.openapi_host}" + configure_openapi_tls(configuration) self.api_client = volcenginesdkcore.ApiClient(configuration=configuration) self.apig_20221112_client = APIG20221112Api(api_client=self.api_client) @@ -90,28 +94,65 @@ def _running(g) -> bool: return running[0] return serverless[0] if serverless else None - def create_serverless_gateway(self, instance_name: str) -> str: # instance + def create_serverless_gateway( + self, + instance_name: str, + *, + vestack_cluster_id: str = "", + vestack_namespace: str = "", + vestack_cluster_name: str = "aio", + ) -> str: # instance from volcenginesdkapig import ( CreateGatewayRequest, ResourceSpecForCreateGatewayInput, ListGatewaysRequest, ) - request = CreateGatewayRequest( - name=instance_name, - region=self.region, - type="serverless", - resource_spec=ResourceSpecForCreateGatewayInput( - replicas=2, - instance_spec_code="1c2g", - clb_spec_code="small_1", - public_network_billing_type="traffic", - network_type={ - "EnablePublicNetwork": True, - "EnablePrivateNetwork": False, + if vestack_cluster_id: + if not vestack_namespace: + raise ValueError("VeStack APIG namespace is required") + # VeStack APIG v2.5 uses the same OpenAPI action/version but a + # cluster-native request shape that is not represented by the + # public-cloud Python SDK models. Extend the generated model so the + # SDK still performs signing, retries, and endpoint configuration. + request = CreateGatewayRequest( + name=instance_name, + region=self.region, + type="serverless", + resource_spec={ + "Replicas": 2, + "CpuRequest": "100m", + "CpuLimit": "1000m", + "MemoryRequest": "128Mi", + "MemoryLimit": "1Gi", }, - ), - ) + ) + request.swagger_types = dict(request.swagger_types) + request.attribute_map = dict(request.attribute_map) + request.swagger_types["resource_spec"] = "object" + request.swagger_types["cluster_spec"] = "object" + request.attribute_map["cluster_spec"] = "ClusterSpec" + request.cluster_spec = { + "ClusterId": vestack_cluster_id, + "Namespace": vestack_namespace, + "ClusterName": vestack_cluster_name, + } + else: + request = CreateGatewayRequest( + name=instance_name, + region=self.region, + type="serverless", + resource_spec=ResourceSpecForCreateGatewayInput( + replicas=2, + instance_spec_code="1c2g", + clb_spec_code="small_1", + public_network_billing_type="traffic", + network_type={ + "EnablePublicNetwork": True, + "EnablePrivateNetwork": False, + }, + ), + ) thread = self.apig_client.create_gateway(request, async_req=True) result = thread.get() gateway_id = result.to_dict()["id"] @@ -132,7 +173,51 @@ def create_serverless_gateway(self, instance_name: str) -> str: # instance time.sleep(5) return gateway_id - def create_gateway_service(self, gateway_id: str, service_name: str) -> str: + def get_gateway_external_http_address(self, gateway_id: str) -> tuple[str, int]: + """Return the VeStack gateway's external IP and allocated HTTP port.""" + from volcenginesdkapig import GetGatewayRequest + + captured: dict[str, object] = {} + rest_client = self.apig_client.api_client.rest_client + original_request = rest_client.request + + def capture_response(*args, **kwargs): + response = original_request(*args, **kwargs) + payload = response.data + if isinstance(payload, bytes): + payload = payload.decode("utf-8") + captured.update(json.loads(payload)) + return response + + rest_client.request = capture_response + try: + self.apig_client.get_gateway(GetGatewayRequest(id=gateway_id)) + finally: + rest_client.request = original_request + + gateway = captured.get("Result", {}).get("Gateway", {}) + external = gateway.get("GatewayExternalAddress", {}) + ips = external.get("IPs", []) + http_item = next( + ( + item + for item in external.get("Items", []) + if str(item.get("Protocol", "")).upper() == "HTTP" + ), + None, + ) + if not ips or not http_item or not http_item.get("Port"): + raise RuntimeError("VeStack APIG did not return an external HTTP address") + return str(ips[0]), int(http_item["Port"]) + + def create_gateway_service( + self, + gateway_id: str, + service_name: str, + custom_domain: str = "", + *, + vestack: bool = False, + ) -> str: """ Create a gateway service. (Domain name) Args: @@ -143,6 +228,7 @@ def create_gateway_service(self, gateway_id: str, service_name: str) -> str: """ from volcenginesdkapig import ( AuthSpecForCreateGatewayServiceInput, + CustomDomainForCreateGatewayServiceInput, CreateGatewayServiceRequest, ) @@ -151,7 +237,28 @@ def create_gateway_service(self, gateway_id: str, service_name: str) -> str: service_name=service_name, protocol=["HTTP", "HTTPS"], auth_spec=AuthSpecForCreateGatewayServiceInput(enable=False), + custom_domains=( + [ + CustomDomainForCreateGatewayServiceInput( + domain=custom_domain, + protocol=["HTTP"], + ssl_redirect=False, + ) + ] + if custom_domain and not vestack + else None + ), ) + if vestack: + if not custom_domain: + raise ValueError("VeStack APIG service requires a domain") + request.swagger_types = dict(request.swagger_types) + request.attribute_map = dict(request.attribute_map) + request.swagger_types["service_domain_spec"] = "object" + request.attribute_map["service_domain_spec"] = "ServiceDomainSpec" + request.service_domain_spec = [ + {"Domain": custom_domain, "Protocol": ["HTTP"]} + ] thread = self.apig_client.create_gateway_service(request, async_req=True) result = thread.get() return result.to_dict()["id"] @@ -177,6 +284,53 @@ def create_vefaas_upstream( result = thread.get() return result.to_dict()["id"] + def find_gateway_service(self, gateway_id: str, name: str): + from volcenginesdkapig import ListGatewayServicesRequest + + result = self.apig_client.list_gateway_services( + ListGatewayServicesRequest( + gateway_id=gateway_id, page_number=1, page_size=100 + ) + ) + return next( + ( + item + for item in (getattr(result, "items", None) or []) + if getattr(item, "name", "") == name + ), + None, + ) + + def find_upstream(self, gateway_id: str, name: str): + from volcenginesdkapig import ListUpstreamsRequest + + result = self.apig_client.list_upstreams( + ListUpstreamsRequest(gateway_id=gateway_id, page_number=1, page_size=100) + ) + return next( + ( + item + for item in (getattr(result, "items", None) or []) + if getattr(item, "name", "") == name + ), + None, + ) + + def find_route(self, service_id: str, name: str): + from volcenginesdkapig20221112 import ListRoutesRequest + + result = self.apig_20221112_client.list_routes( + ListRoutesRequest(service_id=service_id, page_number=1, page_size=100) + ) + return next( + ( + item + for item in (getattr(result, "items", None) or []) + if getattr(item, "name", "") == name + ), + None, + ) + def create_domain_upstream( self, domain: str, diff --git a/veadk/integrations/ve_faas/ve_faas.py b/veadk/integrations/ve_faas/ve_faas.py index 0e7a54ce2..448a23027 100644 --- a/veadk/integrations/ve_faas/ve_faas.py +++ b/veadk/integrations/ve_faas/ve_faas.py @@ -44,6 +44,7 @@ CloudProvider, default_vefaas_application_template_id, vefaas_openapi_host, + configure_openapi_tls, ) from veadk.utils.logger import get_logger from veadk.utils.misc import formatted_timestamp, getenv @@ -157,8 +158,9 @@ def __init__( configuration.sk = self.sk configuration.session_token = self.session_token configuration.region = region - if provider == "byteplus": - configuration.host = f"https://{self.openapi_host}" + scheme = os.getenv("VEFAAS_OPENAPI_SCHEME", "https").strip() or "https" + configuration.host = f"{scheme}://{self.openapi_host}" + configure_openapi_tls(configuration) configuration.client_side_validation = True volcenginesdkcore.Configuration.set_default(configuration) @@ -1063,6 +1065,9 @@ def _create_image_function(self, function_name: str, image: str): source=image, # Container image URL request_timeout=1800, # Request timeout in seconds envs=envs, # Environment variables from configuration + memory_mb=4096, + role=getenv("IAM_ROLE", None, allow_false_values=True), + project_name=self.project_name, ) ) @@ -1189,6 +1194,9 @@ def _create_image_function(self, function_name: str, image: str): source=image, # Container image URL request_timeout=1800, # Request timeout in seconds envs=envs, # Environment variables from configuration + memory_mb=4096, + role=getenv("IAM_ROLE", None, allow_false_values=True), + project_name=self.project_name, ) ) diff --git a/veadk/integrations/ve_identity/identity_client.py b/veadk/integrations/ve_identity/identity_client.py index 6874e83f0..58bc0f8d3 100644 --- a/veadk/integrations/ve_identity/identity_client.py +++ b/veadk/integrations/ve_identity/identity_client.py @@ -40,7 +40,12 @@ from veadk.config import settings from veadk.utils.logger import get_logger -from veadk.utils.cloud_provider import CloudProvider, cloud_provider_from_env +from veadk.utils.cloud_provider import ( + CloudProvider, + cloud_provider_from_env, + configure_openapi_tls, + identity_openapi_host, +) logger = get_logger(__name__) @@ -222,8 +227,11 @@ def __init__( configuration.sk = self._initial_secret_key configuration.session_token = self._initial_session_token configuration.logger = {} - if self.provider == "byteplus": - configuration.host = f"https://id.{region}.byteplusapi.com" + identity_scheme = os.getenv("IDENTITY_OPENAPI_SCHEME", "https").strip() + configuration.host = ( + f"{identity_scheme}://{identity_openapi_host(region, self.provider)}" + ) + configure_openapi_tls(configuration) self._api_client = volcenginesdkid.IDApi( volcenginesdkcore.ApiClient(configuration) diff --git a/veadk/utils/cloud_provider.py b/veadk/utils/cloud_provider.py index 8d5130f08..3b0b38ede 100644 --- a/veadk/utils/cloud_provider.py +++ b/veadk/utils/cloud_provider.py @@ -17,7 +17,7 @@ from __future__ import annotations import os -from typing import Literal +from typing import Any, Literal CloudProvider = Literal["volcengine", "byteplus"] @@ -80,6 +80,8 @@ def default_vefaas_application_template_id( def vefaas_openapi_host(region: str, provider: CloudProvider) -> str: """Return the Function Service OpenAPI host for a provider.""" + if override := os.getenv("VEFAAS_OPENAPI_HOST"): + return override.removeprefix("https://").removeprefix("http://").rstrip("/") if provider == "byteplus": return f"vefaas.{region}.byteplusapi.com" return "open.volcengineapi.com" @@ -87,6 +89,8 @@ def vefaas_openapi_host(region: str, provider: CloudProvider) -> str: def apig_openapi_host(region: str, provider: CloudProvider) -> str: """Return the API Gateway OpenAPI host for a provider.""" + if override := os.getenv("APIG_OPENAPI_HOST"): + return override.removeprefix("https://").removeprefix("http://").rstrip("/") if provider == "byteplus": return f"apig.{region}.byteplusapi.com" return "open.volcengineapi.com" @@ -101,11 +105,38 @@ def cp_openapi_host(region: str, provider: CloudProvider) -> str: def iam_openapi_host(provider: CloudProvider) -> str: """Return the IAM OpenAPI host for a provider.""" + if override := os.getenv("IAM_OPENAPI_HOST"): + return override.removeprefix("https://").removeprefix("http://").rstrip("/") if provider == "byteplus": return "iam.byteplusapi.com" return "iam.volcengineapi.com" +def identity_openapi_host(region: str, provider: CloudProvider) -> str: + """Return the Identity OpenAPI host for a provider.""" + if override := os.getenv("IDENTITY_OPENAPI_HOST"): + return override.removeprefix("https://").removeprefix("http://").rstrip("/") + if provider == "byteplus": + return f"id.{region}.byteplusapi.com" + return "open.volcengineapi.com" + + +def configure_openapi_tls(configuration: Any) -> None: + """Apply explicit TLS settings to a Volcengine generated SDK config. + + Full-stack environments commonly expose one internal OpenAPI endpoint with + a private CA. Prefer a CA bundle. Disabling verification is supported only + when the deployer explicitly opts in via the environment. + """ + if ca_bundle := os.getenv("VOLCENGINE_OPENAPI_CA_BUNDLE", "").strip(): + configuration.ssl_ca_cert = ca_bundle + configuration.verify_ssl = True + return + verify = os.getenv("VOLCENGINE_OPENAPI_VERIFY_SSL", "").strip().lower() + if verify in {"0", "false", "no", "off"}: + configuration.verify_ssl = False + + def apmplus_openapi_host(provider: CloudProvider) -> str: """Return the APMPlus OpenAPI host for a provider.""" if provider == "byteplus": From 84ae3faaa06bb92edc4eea2c083d11afeeb8babf Mon Sep 17 00:00:00 2001 From: "zhenguo.li" Date: Thu, 3 Sep 2026 22:03:06 +0800 Subject: [PATCH 2/2] feat(studio): expose personal sandbox controls to users --- .gitattributes | 2 +- frontend/src/App.tsx | 95 ++- frontend/src/adk/client.ts | 3 + frontend/src/adk/newChatCapabilities.ts | 34 +- frontend/src/adk/sandbox.ts | 3 + frontend/src/ui/MyAgents.tsx | 49 +- frontend/src/ui/SandboxLaunchDialog.tsx | 114 +++- frontend/src/ui/SandboxSession.css | 10 + frontend/tests/myAgents.test.mjs | 25 +- .../tests/sandboxSessionPresentation.test.mjs | 19 +- frontend/tests/studioAccess.test.mjs | 26 +- tests/cli/test_studio_rbac.py | 40 ++ veadk/cli/studio_rbac.py | 1 + .../{index-z3uMxcpH.js => index-DIYVt7Ac.js} | 574 +++++++++--------- ...DWPwBXPg.js => CodeDiffEditor-l11fliVO.js} | 2 +- ...MM.js => MarkdownPromptEditor-Bz4vyefC.js} | 2 +- .../{arc-innlOews.js => arc-DQ7XaXQG.js} | 2 +- ...hannel-DcCjWcbg.js => channel-DegCaiDv.js} | 2 +- ...ex.es-By-P9X2v.js => index.es-QHL07Wk1.js} | 2 +- ...n-CKI8Ibko.js => jspdf.es.min-DDPk9Hol.js} | 6 +- ...{linear-CjjGn77X.js => linear-BAZTlTqj.js} | 2 +- ...{index-VvaO_aPK.css => index-DSepm2I-.css} | 2 +- ...Sm.js => abnfDiagram-N423BO3Z-70LMYaU6.js} | 2 +- ... architectureDiagram-T3A2C74G-DA0NtW-v.js} | 2 +- ...I.js => blockDiagram-VBNYF7ZC-lNi_2qOQ.js} | 2 +- ...bJzI.js => c4Diagram-5PPSVZJV-Chcj5BQP.js} | 2 +- ..._Z8gl5Vi.js => chunk-2GRJ4B5K-DQXHs2q8.js} | 2 +- ...Ce_dMl-u.js => chunk-2Q5K7J3B-BqkBkl83.js} | 2 +- ...VSIIao-f.js => chunk-5RXB4S5H-_JyCcxKo.js} | 2 +- ...FEd0nKdp.js => chunk-5VM5RSS4-CAOJAL1M.js} | 2 +- ...Clwvr7OV.js => chunk-6Q2QTUOP-BHrg-JBr.js} | 2 +- ...t_nXfek4.js => chunk-GF5L2VYU-C9QSqzJc.js} | 2 +- ...DQFINVK7.js => chunk-JWPE2WC7-D5iaDuQ-.js} | 2 +- ...BtFY4xat.js => chunk-KBJHAD2P-CVmaduZS.js} | 2 +- ...DC0Xs0rI.js => chunk-RYQCIY6F-B0rl9cyk.js} | 2 +- ...Da-hwzyB.js => chunk-XXDRQBXY-D-RRpADP.js} | 2 +- .../mermaid/classDiagram-JCYQIIEL-BdKoQVC7.js | 1 + .../mermaid/classDiagram-JCYQIIEL-Du1u2e7v.js | 1 - .../classDiagram-v2-OCEON4UE-BdKoQVC7.js | 1 + .../classDiagram-v2-OCEON4UE-Du1u2e7v.js | 1 - ...J.js => cose-bilkent-JH36ORCC-DVQMmEJR.js} | 2 +- ...pHVvdh.js => cynefin-OW5HDTMX-CGNYr43O.js} | 2 +- ...js => cynefinDiagram-MW4NZA55-BQhWErsC.js} | 2 +- ...2RTnI8zi.js => dagre-VZM6K2ZE-B9kiFAMH.js} | 2 +- ...me8NQq.js => diagram-7IWD3JNH-fBT4KcEW.js} | 2 +- ...Q2MwdN.js => diagram-B4RE2ZJO-CCvzvUat.js} | 2 +- ...dZLdIy.js => diagram-LBJQPF4R-BNlnWR9x.js} | 2 +- ...a5Skap.js => diagram-Q27KOJAE-DNQw9lAO.js} | 2 +- ...zNsKqf.js => diagram-UB23O5K3-BVaBC6mm.js} | 2 +- ...5j.js => ebnfDiagram-BXEA7PRR-BAhzs1Tb.js} | 2 +- ...Ztwc.js => erDiagram-JOGREHBK--eiZTM3U.js} | 2 +- ...En.js => flowDiagram-UKHOOZJN-B97-OI75.js} | 2 +- ...u.js => ganttDiagram-PKOTCBZU-aX2gLW7g.js} | 2 +- ...s => gitGraphDiagram-DS77QQ5N-DGtmto3C.js} | 2 +- ...9j.js => infoDiagram-6WML65LV-CSKkKWTi.js} | 2 +- ...s => ishikawaDiagram-WSZJBQD7-CTmje86R.js} | 2 +- ...js => journeyDiagram-NVQOT4AX-CrIegTdl.js} | 2 +- ...=> kanban-definition-27J2QSJJ-CjJW2sig.js} | 2 +- ...e-BcqeQUkk.js => mermaid.core-4ZUNh835.js} | 8 +- ...> mindmap-definition-FAOFIHXS-CiSO8tdm.js} | 2 +- ...nS1.js => pegDiagram-VL7TDLO6-BFbTMPxT.js} | 2 +- ...5vc.js => pieDiagram-7S7Q4E2Y-BZw6Dp-p.js} | 2 +- ...s => quadrantDiagram-CIZ2JOQS-HbO69UQs.js} | 2 +- ...s => railroadDiagram-AXF67PYL-CtKanr4H.js} | 2 +- ...> requirementDiagram-LRYGKXZP-DL3LOJJz.js} | 2 +- ....js => sankeyDiagram-W5VNT64P-DHWVRYmx.js} | 2 +- ...s => sequenceDiagram-SI44F4Z6-B9Cuv6Sh.js} | 2 +- ..._T.js => sizeCapture-X5ZJPWSS-B552ASl5.js} | 2 +- ...T.js => stateDiagram-OKZ733FA-CnuuSFwJ.js} | 2 +- .../stateDiagram-v2-UEYNNEHI-Cy8zD_LJ.js | 1 + .../stateDiagram-v2-UEYNNEHI-IX0T9GU2.js | 1 - ...36DN.js => swimlanes-SLNWSIFB-Bn-aoaui.js} | 4 +- .../swimlanesDiagram-ULZ7WXOC-BSGJZU8B.js | 8 + .../swimlanesDiagram-ULZ7WXOC-C8-jIa75.js | 8 - ... timeline-definition-Z64GVDOM-D7IIyJPT.js} | 2 +- ...w6.js => vennDiagram-T6HMQDX7-D6wlwNpz.js} | 2 +- ...js => wardleyDiagram-T6FBY63Y-BNdis_yh.js} | 2 +- ...js => xychartDiagram-ELKLHX3M-DfQ8fEJs.js} | 2 +- veadk/webui/index.html | 4 +- 79 files changed, 690 insertions(+), 455 deletions(-) rename veadk/webui/assets/app/{index-z3uMxcpH.js => index-DIYVt7Ac.js} (59%) rename veadk/webui/assets/chunks/{CodeDiffEditor-DWPwBXPg.js => CodeDiffEditor-l11fliVO.js} (99%) rename veadk/webui/assets/chunks/{MarkdownPromptEditor-YxGdWdMM.js => MarkdownPromptEditor-Bz4vyefC.js} (99%) rename veadk/webui/assets/chunks/{arc-innlOews.js => arc-DQ7XaXQG.js} (98%) rename veadk/webui/assets/chunks/{channel-DcCjWcbg.js => channel-DegCaiDv.js} (53%) rename veadk/webui/assets/chunks/{index.es-By-P9X2v.js => index.es-QHL07Wk1.js} (99%) rename veadk/webui/assets/chunks/{jspdf.es.min-CKI8Ibko.js => jspdf.es.min-DDPk9Hol.js} (99%) rename veadk/webui/assets/chunks/{linear-CjjGn77X.js => linear-BAZTlTqj.js} (98%) rename veadk/webui/assets/styles/{index-VvaO_aPK.css => index-DSepm2I-.css} (95%) rename veadk/webui/assets/visualizations/mermaid/{abnfDiagram-N423BO3Z-C8Tx4-Sm.js => abnfDiagram-N423BO3Z-70LMYaU6.js} (86%) rename veadk/webui/assets/visualizations/mermaid/{architectureDiagram-T3A2C74G-BAggTn2V.js => architectureDiagram-T3A2C74G-DA0NtW-v.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{blockDiagram-VBNYF7ZC-0g1qw4pI.js => blockDiagram-VBNYF7ZC-lNi_2qOQ.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{c4Diagram-5PPSVZJV-Cz_JbJzI.js => c4Diagram-5PPSVZJV-Chcj5BQP.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-2GRJ4B5K-_Z8gl5Vi.js => chunk-2GRJ4B5K-DQXHs2q8.js} (93%) rename veadk/webui/assets/visualizations/mermaid/{chunk-2Q5K7J3B-Ce_dMl-u.js => chunk-2Q5K7J3B-BqkBkl83.js} (66%) rename veadk/webui/assets/visualizations/mermaid/{chunk-5RXB4S5H-VSIIao-f.js => chunk-5RXB4S5H-_JyCcxKo.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-5VM5RSS4-FEd0nKdp.js => chunk-5VM5RSS4-CAOJAL1M.js} (83%) rename veadk/webui/assets/visualizations/mermaid/{chunk-6Q2QTUOP-Clwvr7OV.js => chunk-6Q2QTUOP-BHrg-JBr.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-GF5L2VYU-t_nXfek4.js => chunk-GF5L2VYU-C9QSqzJc.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-JWPE2WC7-DQFINVK7.js => chunk-JWPE2WC7-D5iaDuQ-.js} (78%) rename veadk/webui/assets/visualizations/mermaid/{chunk-KBJHAD2P-BtFY4xat.js => chunk-KBJHAD2P-CVmaduZS.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{chunk-RYQCIY6F-DC0Xs0rI.js => chunk-RYQCIY6F-B0rl9cyk.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-XXDRQBXY-Da-hwzyB.js => chunk-XXDRQBXY-D-RRpADP.js} (53%) create mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-JCYQIIEL-BdKoQVC7.js delete mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-JCYQIIEL-Du1u2e7v.js create mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-v2-OCEON4UE-BdKoQVC7.js delete mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-v2-OCEON4UE-Du1u2e7v.js rename veadk/webui/assets/visualizations/mermaid/{cose-bilkent-JH36ORCC-CZhHSgDJ.js => cose-bilkent-JH36ORCC-DVQMmEJR.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{cynefin-OW5HDTMX-DrpHVvdh.js => cynefin-OW5HDTMX-CGNYr43O.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{cynefinDiagram-MW4NZA55-DgNXuzlz.js => cynefinDiagram-MW4NZA55-BQhWErsC.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{dagre-VZM6K2ZE-2RTnI8zi.js => dagre-VZM6K2ZE-B9kiFAMH.js} (97%) rename veadk/webui/assets/visualizations/mermaid/{diagram-7IWD3JNH-DIme8NQq.js => diagram-7IWD3JNH-fBT4KcEW.js} (96%) rename veadk/webui/assets/visualizations/mermaid/{diagram-B4RE2ZJO-BuQ2MwdN.js => diagram-B4RE2ZJO-CCvzvUat.js} (97%) rename veadk/webui/assets/visualizations/mermaid/{diagram-LBJQPF4R-cjdZLdIy.js => diagram-LBJQPF4R-BNlnWR9x.js} (94%) rename veadk/webui/assets/visualizations/mermaid/{diagram-Q27KOJAE-BAa5Skap.js => diagram-Q27KOJAE-DNQw9lAO.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{diagram-UB23O5K3-CDzNsKqf.js => diagram-UB23O5K3-BVaBC6mm.js} (95%) rename veadk/webui/assets/visualizations/mermaid/{ebnfDiagram-BXEA7PRR-C-gxgM5j.js => ebnfDiagram-BXEA7PRR-BAhzs1Tb.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{erDiagram-JOGREHBK-BzylZtwc.js => erDiagram-JOGREHBK--eiZTM3U.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{flowDiagram-UKHOOZJN-BqZjLtEn.js => flowDiagram-UKHOOZJN-B97-OI75.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{ganttDiagram-PKOTCBZU-D2FqStFu.js => ganttDiagram-PKOTCBZU-aX2gLW7g.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{gitGraphDiagram-DS77QQ5N-BSyKkt5v.js => gitGraphDiagram-DS77QQ5N-DGtmto3C.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{infoDiagram-6WML65LV-Dj7kkP9j.js => infoDiagram-6WML65LV-CSKkKWTi.js} (69%) rename veadk/webui/assets/visualizations/mermaid/{ishikawaDiagram-WSZJBQD7-Cf7f3v2E.js => ishikawaDiagram-WSZJBQD7-CTmje86R.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{journeyDiagram-NVQOT4AX-C41U2Luv.js => journeyDiagram-NVQOT4AX-CrIegTdl.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{kanban-definition-27J2QSJJ-BKs_5k-a.js => kanban-definition-27J2QSJJ-CjJW2sig.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{mermaid.core-BcqeQUkk.js => mermaid.core-4ZUNh835.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{mindmap-definition-FAOFIHXS-D0X3SZvV.js => mindmap-definition-FAOFIHXS-CiSO8tdm.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{pegDiagram-VL7TDLO6-Bnvb8nS1.js => pegDiagram-VL7TDLO6-BFbTMPxT.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{pieDiagram-7S7Q4E2Y-D3fc45vc.js => pieDiagram-7S7Q4E2Y-BZw6Dp-p.js} (96%) rename veadk/webui/assets/visualizations/mermaid/{quadrantDiagram-CIZ2JOQS-CoHR4pau.js => quadrantDiagram-CIZ2JOQS-HbO69UQs.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{railroadDiagram-AXF67PYL-gG1OeQP-.js => railroadDiagram-AXF67PYL-CtKanr4H.js} (84%) rename veadk/webui/assets/visualizations/mermaid/{requirementDiagram-LRYGKXZP-p-OEadEs.js => requirementDiagram-LRYGKXZP-DL3LOJJz.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{sankeyDiagram-W5VNT64P-Ceg5CMsk.js => sankeyDiagram-W5VNT64P-DHWVRYmx.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{sequenceDiagram-SI44F4Z6--89iLEbs.js => sequenceDiagram-SI44F4Z6-B9Cuv6Sh.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{sizeCapture-X5ZJPWSS-RI5SEi_T.js => sizeCapture-X5ZJPWSS-B552ASl5.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{stateDiagram-OKZ733FA-aHRM5UiT.js => stateDiagram-OKZ733FA-CnuuSFwJ.js} (96%) create mode 100644 veadk/webui/assets/visualizations/mermaid/stateDiagram-v2-UEYNNEHI-Cy8zD_LJ.js delete mode 100644 veadk/webui/assets/visualizations/mermaid/stateDiagram-v2-UEYNNEHI-IX0T9GU2.js rename veadk/webui/assets/visualizations/mermaid/{swimlanes-SLNWSIFB-Cb6P36DN.js => swimlanes-SLNWSIFB-Bn-aoaui.js} (99%) create mode 100644 veadk/webui/assets/visualizations/mermaid/swimlanesDiagram-ULZ7WXOC-BSGJZU8B.js delete mode 100644 veadk/webui/assets/visualizations/mermaid/swimlanesDiagram-ULZ7WXOC-C8-jIa75.js rename veadk/webui/assets/visualizations/mermaid/{timeline-definition-Z64GVDOM-CGWsxSP5.js => timeline-definition-Z64GVDOM-D7IIyJPT.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{vennDiagram-T6HMQDX7-DrXaWmw6.js => vennDiagram-T6HMQDX7-D6wlwNpz.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{wardleyDiagram-T6FBY63Y-hALHoeXO.js => wardleyDiagram-T6FBY63Y-BNdis_yh.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{xychartDiagram-ELKLHX3M-lylc5k8g.js => xychartDiagram-ELKLHX3M-DfQ8fEJs.js} (99%) diff --git a/.gitattributes b/.gitattributes index 5a44e8963..8b5f008ca 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,3 @@ # Minified build artifacts can contain semantic whitespace inside template literals. -veadk/webui/assets/*.js -whitespace +veadk/webui/assets/**/*.js -whitespace *.sh text eol=lf diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5b0e525c7..2e3c002fe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1058,6 +1058,17 @@ export default function App() { const [sandboxLaunchError, setSandboxLaunchError] = useState(""); const [sandboxLaunchKind, setSandboxLaunchKind] = useState<"codex" | SandboxAgentKind>("codex"); + const [sandboxLaunchPersistentEnabled, setSandboxLaunchPersistentEnabled] = + useState(true); + const [sandboxLaunchPersistentReason, setSandboxLaunchPersistentReason] = + useState(""); + const [sandboxLaunchPersistentRequired, setSandboxLaunchPersistentRequired] = + useState(false); + const [sandboxLaunchStorageMode, setSandboxLaunchStorageMode] = + useState<"snapshot" | "disk">("snapshot"); + const [sandboxLaunchDiskGbDefault, setSandboxLaunchDiskGbDefault] = useState(10); + const [sandboxLaunchDiskGbMin, setSandboxLaunchDiskGbMin] = useState(5); + const [sandboxLaunchDiskGbMax, setSandboxLaunchDiskGbMax] = useState(100); const [sandboxLaunchFromAgents, setSandboxLaunchFromAgents] = useState(false); const [sandboxProjectUploadOpen, setSandboxProjectUploadOpen] = useState(false); const [sandboxAgentRefreshKey, setSandboxAgentRefreshKey] = useState(0); @@ -1069,6 +1080,7 @@ export default function App() { const [sandboxThreadDeleteTarget, setSandboxThreadDeleteTarget] = useState(null); const sandboxLaunchAbortRef = useRef(null); + const sandboxLaunchCapabilityAbortRef = useRef(null); const intelligentCreateAbortRef = useRef(null); const sandboxMessageAbortRef = useRef(null); const pendingIntelligentNavigationRef = useRef<(() => void) | null>(null); @@ -1083,6 +1095,9 @@ export default function App() { const sandboxEndpointCopyTimerRef = useRef(undefined); const sandboxPreviewUrlsRef = useRef>(new Set()); sandboxSessionIdRef.current = sandboxSession?.id ?? ""; + useEffect(() => () => { + sandboxLaunchCapabilityAbortRef.current?.abort(); + }, []); useEffect(() => () => { if (sandboxEndpointCopyTimerRef.current !== undefined) { window.clearTimeout(sandboxEndpointCopyTimerRef.current); @@ -3525,11 +3540,42 @@ export default function App() { setSandboxLaunchError(""); setSandboxLaunchState("confirm"); setSandboxLaunchKind(kind); + setSandboxLaunchPersistentEnabled(false); + setSandboxLaunchPersistentReason("正在检查持久化能力…"); + setSandboxLaunchPersistentRequired(false); + setSandboxLaunchStorageMode("snapshot"); + setSandboxLaunchDiskGbDefault(10); + setSandboxLaunchDiskGbMin(5); + setSandboxLaunchDiskGbMax(100); + sandboxLaunchCapabilityAbortRef.current?.abort(); + const controller = new AbortController(); + sandboxLaunchCapabilityAbortRef.current = controller; + const capabilityRequest = kind === "codex" + ? getSandboxCapability(controller.signal) + : getSandboxAgentCapability(kind, controller.signal); + void capabilityRequest + .then((capability) => { + if (controller.signal.aborted) return; + setSandboxLaunchPersistentEnabled(capability.persistentEnabled === true); + setSandboxLaunchPersistentReason(capability.persistentReason ?? ""); + setSandboxLaunchPersistentRequired(capability.persistentRequired === true); + setSandboxLaunchStorageMode(capability.storageMode ?? "snapshot"); + setSandboxLaunchDiskGbDefault(capability.diskGbDefault ?? 10); + setSandboxLaunchDiskGbMin(capability.diskGbMin ?? 5); + setSandboxLaunchDiskGbMax(capability.diskGbMax ?? 100); + }) + .catch((cause) => { + if ((cause as Error)?.name === "AbortError") return; + setSandboxLaunchPersistentEnabled(false); + setSandboxLaunchPersistentReason("暂时无法确认持久化能力"); + }); setSandboxLaunchFromAgents(fromAgents); setSandboxLaunchOpen(true); } function cancelSandboxLaunch() { + sandboxLaunchCapabilityAbortRef.current?.abort(); + sandboxLaunchCapabilityAbortRef.current = null; sandboxLaunchAbortRef.current?.abort(); sandboxLaunchAbortRef.current = null; setSandboxLaunchOpen(false); @@ -3544,7 +3590,11 @@ export default function App() { } } - async function launchSandboxSession(displayName: string, persistent: boolean) { + async function launchSandboxSession( + displayName: string, + persistent: boolean, + diskGb?: number, + ) { sandboxLaunchAbortRef.current?.abort(); const controller = new AbortController(); sandboxLaunchAbortRef.current = controller; @@ -3559,11 +3609,13 @@ export default function App() { ? await sandboxClient.startSession({ displayName, persistent, + diskGb, signal: controller.signal, }) : await sandboxClient.startAgentSession(sandboxLaunchKind, { displayName, persistent, + diskGb, signal: controller.signal, }); if (sandboxLaunchAbortRef.current !== controller) { @@ -5473,12 +5525,13 @@ export default function App() { return
; } - const canCreateAgents = access.capabilities.createAgents; + const canCreateRuntimeAgents = access.capabilities.createAgents; + const canCreatePersonalAgents = access.capabilities.createPersonalAgents; const canManageAgents = access.capabilities.manageAgents; const canViewAgentUsage = features.agentUsage && canManageAgents; - const visibleCreateView = canCreateAgents ? createView : null; - const showAddMenu = canCreateAgents && addMenu; - const showAddAgent = canCreateAgents && addAgent; + const visibleCreateView = canCreateRuntimeAgents ? createView : null; + const showAddMenu = canCreateRuntimeAgents && addMenu; + const showAddAgent = canCreateRuntimeAgents && addAgent; const showManageAgents = manageAgents && Boolean( agentDetailTarget || focusedDeploymentTaskId || focusedWorkspaceAgentId, ); @@ -5912,7 +5965,7 @@ export default function App() { }; const openAgentCreateFromMyAgents = (region: string) => { - if (!canCreateAgents) { + if (!canCreateRuntimeAgents) { setError("当前账号没有添加 Agent 的权限。"); return; } @@ -6012,7 +6065,7 @@ export default function App() { const openSandboxAgentCreate = ( kind: "codex" | SandboxAgentKind, ) => { - if (!canCreateAgents) { + if (!canCreatePersonalAgents) { setError("当前账号没有创建智能体的权限。"); return; } @@ -6292,7 +6345,7 @@ export default function App() { setError(""); })} onQuickCreate={() => requestIntelligentNavigation(() => { - if (!canCreateAgents) { + if (!canCreateRuntimeAgents) { setError("当前账号没有添加 Agent 的权限。"); return; } @@ -6343,7 +6396,7 @@ export default function App() { setError(""); })} onAddAgent={() => requestIntelligentNavigation(() => { - if (!canCreateAgents) { + if (!canCreateRuntimeAgents) { setError("当前账号没有添加 Agent 的权限。"); return; } @@ -6825,8 +6878,9 @@ export default function App() { setSandboxProjectUploadOpen(true)} @@ -6877,8 +6931,8 @@ export default function App() { agentInfo={agentInfo} agentInfoAgentId={appName} loadingAgentInfo={capabilitiesLoading} - canCreate={canCreateAgents} - canUpdate={canCreateAgents || canManageAgents} + canCreate={canCreateRuntimeAgents} + canUpdate={canCreateRuntimeAgents || canManageAgents} canViewUsage={canViewAgentUsage} loadingAgents={agentLibraryLoading} agentsError={agentLibraryError} @@ -6899,7 +6953,7 @@ export default function App() { onOpenFeedbackCase={(item) => void openFeedbackCaseInStudio(item)} onFeedbackCasesDeleted={clearDeletedFeedbackCases} onCreateAgent={() => { - if (!canCreateAgents) { + if (!canCreateRuntimeAgents) { setError("当前账号没有添加 Agent 的权限。"); return; } @@ -6917,7 +6971,7 @@ export default function App() { setError(""); }} onUpdateAgent={async (capability) => { - if (!canManageAgents && !canCreateAgents) { + if (!canManageAgents && !canCreateRuntimeAgents) { setError("当前账号没有管理 Agent 的权限。"); return; } @@ -7740,9 +7794,16 @@ export default function App() { state={sandboxLaunchState} agentKind={sandboxLaunchKind} error={sandboxLaunchError} + persistentEnabled={sandboxLaunchPersistentEnabled} + persistentReason={sandboxLaunchPersistentReason} + persistentRequired={sandboxLaunchPersistentRequired} + storageMode={sandboxLaunchStorageMode} + diskGbDefault={sandboxLaunchDiskGbDefault} + diskGbMin={sandboxLaunchDiskGbMin} + diskGbMax={sandboxLaunchDiskGbMax} onCancel={cancelSandboxLaunch} - onConfirm={(displayName, persistent) => - void launchSandboxSession(displayName, persistent) + onConfirm={(displayName, persistent, diskGb) => + void launchSandboxSession(displayName, persistent, diskGb) } /> diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index 819bd7f97..1c553e84a 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -3743,6 +3743,7 @@ export interface StudioAccess { }; capabilities: { createAgents: boolean; + createPersonalAgents: boolean; manageAgents: boolean; runtimeScope: RuntimeScope; }; @@ -3757,6 +3758,7 @@ export const DEFAULT_STUDIO_ACCESS: StudioAccess = { }, capabilities: { createAgents: false, + createPersonalAgents: false, manageAgents: false, runtimeScope: "mine", }, @@ -3775,6 +3777,7 @@ export async function getStudioAccess(): Promise { typeof access.telemetry.accountId !== "string" ) || typeof access.capabilities?.createAgents !== "boolean" || + typeof access.capabilities?.createPersonalAgents !== "boolean" || typeof access.capabilities?.manageAgents !== "boolean" || !["all", "mine"].includes(access.capabilities?.runtimeScope) ) { diff --git a/frontend/src/adk/newChatCapabilities.ts b/frontend/src/adk/newChatCapabilities.ts index 8b0feda31..10872730c 100644 --- a/frontend/src/adk/newChatCapabilities.ts +++ b/frontend/src/adk/newChatCapabilities.ts @@ -9,12 +9,22 @@ export interface NewChatModeCapability { enabled: boolean; reason?: string; endpointExportEnabled?: boolean; + persistentEnabled?: boolean; + persistentReason?: string; + persistentRequired?: boolean; + storageMode?: "snapshot" | "disk"; + diskGbDefault?: number; + diskGbMin?: number; + diskGbMax?: number; } -async function getCapability(path: string): Promise { +async function getCapability( + path: string, + signal?: AbortSignal, +): Promise { const response = await fetch(withAuth(path), { headers: withLocalUser({ Accept: "application/json" }), - signal: requestSignal(undefined, CAPABILITY_TIMEOUT_MS), + signal: requestSignal(signal, CAPABILITY_TIMEOUT_MS), }); if (!response.ok) { throw new Error(`读取会话模式能力失败(HTTP ${response.status})`); @@ -27,15 +37,29 @@ async function getCapability(path: string): Promise { enabled: payload.enabled, reason: typeof payload.reason === "string" ? payload.reason : undefined, endpointExportEnabled: payload.endpointExportEnabled === true, + persistentEnabled: payload.persistentEnabled === true, + persistentReason: typeof payload.persistentReason === "string" + ? payload.persistentReason + : undefined, + persistentRequired: payload.persistentRequired === true, + storageMode: payload.storageMode === "disk" ? "disk" : "snapshot", + diskGbDefault: typeof payload.diskGbDefault === "number" + ? payload.diskGbDefault + : undefined, + diskGbMin: typeof payload.diskGbMin === "number" ? payload.diskGbMin : undefined, + diskGbMax: typeof payload.diskGbMax === "number" ? payload.diskGbMax : undefined, }; } -export async function getSandboxCapability(): Promise { - return getCapability("/web/sandbox/capabilities"); +export async function getSandboxCapability( + signal?: AbortSignal, +): Promise { + return getCapability("/web/sandbox/capabilities", signal); } export async function getSandboxAgentCapability( kind: SandboxAgentKind, + signal?: AbortSignal, ): Promise { - return getCapability(`/web/${kind}/capabilities`); + return getCapability(`/web/${kind}/capabilities`, signal); } diff --git a/frontend/src/adk/sandbox.ts b/frontend/src/adk/sandbox.ts index a296e8ada..09cc6a582 100644 --- a/frontend/src/adk/sandbox.ts +++ b/frontend/src/adk/sandbox.ts @@ -231,6 +231,7 @@ export interface SandboxStartOptions extends SandboxRequestOptions { displayName?: string; modelId?: string; persistent?: boolean; + diskGb?: number; projectId?: string; baseVersionId?: string; } @@ -1237,6 +1238,7 @@ function createSandboxClient( } : {}), ...(config.textOnly ? {} : { persistent: options.persistent ?? true }), + ...(options.diskGb !== undefined ? { diskGb: options.diskGb } : {}), }), signal: options.signal, }, @@ -1283,6 +1285,7 @@ function createSandboxClient( body: JSON.stringify({ displayName: options.displayName?.trim() ?? "", persistent: options.persistent ?? true, + ...(options.diskGb !== undefined ? { diskGb: options.diskGb } : {}), }), signal: options.signal, }, diff --git a/frontend/src/ui/MyAgents.tsx b/frontend/src/ui/MyAgents.tsx index ff54b063a..36337f701 100644 --- a/frontend/src/ui/MyAgents.tsx +++ b/frontend/src/ui/MyAgents.tsx @@ -23,8 +23,6 @@ import { import { cloudRegionOptions, defaultCloudRegion, - isSupportedCloudRegion, - type CloudRegion, type CloudProvider, } from "../adk/cloudProvider"; import { @@ -299,20 +297,13 @@ function runtimeDetailTargetForCard( function resolveAgentRegion( studioRegion: string, cloudProvider: CloudProvider, -): CloudRegion { - const providerRegions = cloudRegionOptions(cloudProvider); - if ( - isSupportedCloudRegion(studioRegion) && - providerRegions.some((option) => option.value === studioRegion) - ) { - return studioRegion; - } - return defaultCloudRegion(cloudProvider); +): string { + return studioRegion.trim() || defaultCloudRegion(cloudProvider); } async function loadRuntimeAgents( runtimeScope: RuntimeScope, - region: CloudRegion, + region: string, nextToken: string, onList: (agents: MyAgentCardData[]) => void, signal?: AbortSignal, @@ -604,7 +595,8 @@ function AgentCard({ export interface MyAgentsProps { cloudProvider: CloudProvider; studioRegion: string; - canCreate: boolean; + canCreateRuntimeAgents: boolean; + canCreatePersonalAgents: boolean; canUpdate: boolean; runtimeScope: RuntimeScope; onCreateAgent: (region: string) => void; @@ -630,7 +622,8 @@ export interface MyAgentsProps { export function MyAgents({ cloudProvider, studioRegion, - canCreate, + canCreateRuntimeAgents, + canCreatePersonalAgents, canUpdate, runtimeScope, onCreateAgent, @@ -664,7 +657,7 @@ export function MyAgents({ const [ownership, setOwnership] = useState( runtimeScope === "mine" ? "mine" : "all", ); - const [region, setRegion] = useState(configuredRegion); + const [region, setRegion] = useState(configuredRegion); const [runtimeAgents, setRuntimeAgents] = useState([]); const [runtimeNextToken, setRuntimeNextToken] = useState(""); const [loadingRuntimes, setLoadingRuntimes] = useState(true); @@ -678,10 +671,17 @@ export function MyAgents({ >({}); const [draftToDelete, setDraftToDelete] = useState(null); const [remainingTimeNow, setRemainingTimeNow] = useState(() => Date.now()); - const regionFilterOptions = useMemo>>( - () => cloudRegionOptions(cloudProvider), - [cloudProvider], - ); + const regionFilterOptions = useMemo>>(() => { + const providerOptions: Array> = + cloudRegionOptions(cloudProvider); + if (providerOptions.some((option) => option.value === configuredRegion)) { + return providerOptions; + } + return [ + { value: configuredRegion, label: configuredRegion }, + ...providerOptions, + ]; + }, [cloudProvider, configuredRegion]); useEffect(() => { if (runtimeScope === "mine") setOwnership("mine"); @@ -948,7 +948,7 @@ export function MyAgents({ setOwnership(nextOwnership); } - function selectRegion(nextRegion: CloudRegion) { + function selectRegion(nextRegion: string) { if (nextRegion === region) return; resetRuntimePagination(); setRegion(nextRegion); @@ -1139,13 +1139,18 @@ export function MyAgents({ ? loadingRuntimes && runtimeAgents.length === 0 && draftAgents.length === 0 : loadingSandboxAgents && sandboxAgents.length === 0; const showEmpty = !showInitialLoading && visibleAgents.length === 0; - const createAgent = canCreate + const canCreateActiveAgent = activeType === "general" + ? canCreateRuntimeAgents + : canCreatePersonalAgents; + const createAgent = canCreateActiveAgent ? activeType === "general" ? () => onCreateAgent(region) : () => onCreateSandboxAgent(activeType) : undefined; const showCodexProjectUpload = - activeType === "codex" && canCreate && Boolean(onOpenCodexProjectUpload); + activeType === "codex" && + canCreatePersonalAgents && + Boolean(onOpenCodexProjectUpload); return ( diff --git a/frontend/src/ui/SandboxLaunchDialog.tsx b/frontend/src/ui/SandboxLaunchDialog.tsx index 15e999893..41b71a4ec 100644 --- a/frontend/src/ui/SandboxLaunchDialog.tsx +++ b/frontend/src/ui/SandboxLaunchDialog.tsx @@ -15,8 +15,15 @@ export interface SandboxLaunchDialogProps { state: SandboxLaunchState; agentKind?: "codex" | SandboxAgentKind; error?: string; + persistentEnabled?: boolean; + persistentReason?: string; + persistentRequired?: boolean; + storageMode?: "snapshot" | "disk"; + diskGbDefault?: number; + diskGbMin?: number; + diskGbMax?: number; onCancel: () => void; - onConfirm: (displayName: string, persistent: boolean) => void; + onConfirm: (displayName: string, persistent: boolean, diskGb?: number) => void; } export function SandboxLaunchDialog({ @@ -24,6 +31,13 @@ export function SandboxLaunchDialog({ state, agentKind = "codex", error, + persistentEnabled = true, + persistentReason = "", + persistentRequired = false, + storageMode = "snapshot", + diskGbDefault = 10, + diskGbMin = 5, + diskGbMax = 100, onCancel, onConfirm, }: SandboxLaunchDialogProps) { @@ -42,12 +56,14 @@ export function SandboxLaunchDialog({ const onCancelRef = useRef(onCancel); const [displayName, setDisplayName] = useState(defaultDisplayName); const [persistent, setPersistent] = useState(true); + const [diskGb, setDiskGb] = useState(diskGbDefault); onCancelRef.current = onCancel; useEffect(() => { if (!open) return; setDisplayName(defaultDisplayName); - setPersistent(true); + setPersistent(persistentRequired || persistentEnabled); + setDiskGb(diskGbDefault); const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; const focusFrame = window.requestAnimationFrame(() => { @@ -81,12 +97,13 @@ export function SandboxLaunchDialog({ document.body.style.overflow = previousOverflow; window.removeEventListener("keydown", handleKeyDown); }; - }, [defaultDisplayName, open]); + }, [defaultDisplayName, diskGbDefault, open, persistentEnabled, persistentRequired]); if (!open) return null; const loading = state === "loading"; const validDisplayName = displayName.trim(); + const validDiskGb = Number.isInteger(diskGb) && diskGb >= diskGbMin && diskGb <= diskGbMax; const title = loading ? `正在创建 ${agentLabel} 智能体` : state === "error" @@ -109,8 +126,17 @@ export function SandboxLaunchDialog({ aria-describedby={state === "confirm" ? undefined : "sandbox-dialog-description"} onSubmit={(event) => { event.preventDefault(); - if (!loading && !composingRef.current && validDisplayName) { - onConfirm(validDisplayName, persistent); + if ( + !loading && + !composingRef.current && + validDisplayName && + (storageMode !== "disk" || validDiskGb) + ) { + onConfirm( + validDisplayName, + storageMode === "disk" ? true : persistent, + storageMode === "disk" ? diskGb : undefined, + ); } }} > @@ -171,38 +197,66 @@ export function SandboxLaunchDialog({ }} /> -
- -

+ + 存储大小 + GiB + + setDiskGb(event.currentTarget.valueAsNumber)} + /> + + 数据将持久化保存,可设置 {diskGbMin}–{diskGbMax} GiB。 + + + ) : ( +

- {persistent - ? "保留智能体数据,后续可继续使用。" - : "智能体将在 8 小时后清空"} -

-
+ +

+ {!persistentEnabled + ? persistentReason || "当前环境不支持快照持久化" + : persistent + ? "保留智能体数据,后续可继续使用。" + : "智能体将在 8 小时后清空"} +

+
+ )}