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/.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/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/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 小时后清空"} +

+
+ )}